# Google Slides Presentation

> Create high-quality Google Slides presentations programmatically using the Google Slides API via the gws CLI. Use when the user asks to "create a presentation", "make a deck", "build slides", "Google Slides", "generate a slide deck", or any request to produce a presentation in Google Slides.

- Skill: `nirjhar17/google-slides-presentation` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add nirjhar17/google-slides-presentation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nirjhar17/google-slides-presentation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: nirjhar17 (https://skillmd.com/u/nirjhar17)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/nirjhar17/google-slides-presentation

---


# Google Slides Presentation Generator

Build polished, branded Google Slides presentations by generating a Python
script that calls the Slides API `batchUpdate` via the `gws` CLI.

## When to Use

- "Create a presentation about X"
- "Make me a slide deck for Y"
- "Build Google Slides for Z"
- "Generate a deck on topic T"
- Any request where the deliverable is a Google Slides presentation.

## Architecture — Two Approaches

Use **Approach A (REST API via gws)** by default. Everything runs inside
Cursor — no browser steps, link delivered in chat. Use **Approach B
(Apps Script)** only when the user explicitly requests it or needs
advanced features like inserting images from URLs.

---

## Approach A — REST API via gws (Default)

This approach generates a Python script that calls the Slides REST API
`batchUpdate` via the `gws` CLI. Everything stays inside Cursor.

### A1 — Gather Content

Before writing any code, understand:

1. **Topic and audience** — who will see the deck and what is the goal
2. **Source material** — documents, notes, data the user has shared
3. **Branding** — default to Red Hat branding unless user specifies otherwise
4. **Slide count** — aim for 10-15 slides for a strategic deck
5. **Color mode** — always ask the user: "Light mode or dark mode?" before generating any slides. Do not assume a default — wait for their answer.

Synthesize all source material into a clear narrative structure before
touching any code. Outline the slide titles and key content for each.

### A2 — Create the Presentation (Blank Slides + Manual Branding)

Create a **fresh blank Google Slides presentation** — do NOT copy the Red Hat
template. The template master (`simple-light-2`) does not support
`predefinedLayout: "BLANK"`, which causes 400 errors. Red Hat Display and
Red Hat Text are Google Fonts and load correctly in any fresh Slides file.

Save the presentation ID. The fresh presentation starts with one default slide:

```python
r = subprocess.run(['gws', 'slides', 'presentations', 'get',
    '--params', json.dumps({
        'presentationId': PRES_ID,
        'fields': 'slides(objectId)'
    })], capture_output=True, text=True)
lines = r.stdout.strip().split('\n')
start = next((i for i,l in enumerate(lines) if l.strip().startswith('{')), 0)
data = json.loads('\n'.join(lines[start:]))
existing_slides = [s['objectId'] for s in data.get('slides', [])]
```

**Create slides using `predefinedLayout: "BLANK"` only — and ALWAYS set background:**

```python
def create_blank_slide(slide_id):
    """ALWAYS use predefinedLayout BLANK. NEVER use layoutId from template."""
    return {"createSlide": {
        "objectId": slide_id,
        "slideLayoutReference": {"predefinedLayout": "BLANK"}
    }}

def slide_bg(slide_id, color):
    """Set explicit slide background. MUST be called immediately after create_blank_slide."""
    return {"updatePageProperties": {
        "objectId": slide_id,
        "pageProperties": {
            "pageBackgroundFill": {
                "solidFill": {"color": {"rgbColor": color}, "alpha": 1.0}
            }
        },
        "fields": "pageBackgroundFill.solidFill.color,pageBackgroundFill.solidFill.alpha"
    }}
```

**MANDATORY SLIDE CREATION PATTERN — follow this for EVERY slide:**

```python
# Step 1: Set BG_PRIMARY at the top of the script based on COLOR_MODE
COLOR_MODE = "light"  # or "dark" — set once, use everywhere

if COLOR_MODE == "light":
    BG_PRIMARY    = {"red": 1.0,   "green": 1.0,   "blue": 1.0}    # WHITE
    TEXT_PRIMARY  = {"red": 0.082, "green": 0.082, "blue": 0.082}  # GRAY_95
    TEXT_SECONDARY= {"red": 0.302, "green": 0.302, "blue": 0.302}  # GRAY_60
elif COLOR_MODE == "dark":
    BG_PRIMARY    = {"red": 0.082, "green": 0.082, "blue": 0.082}  # GRAY_95
    TEXT_PRIMARY  = {"red": 1.0,   "green": 1.0,   "blue": 1.0}    # WHITE
    TEXT_SECONDARY= {"red": 0.639, "green": 0.639, "blue": 0.639}  # GRAY_40

ACCENT = {"red": 0.933, "green": 0.0, "blue": 0.0}  # RH_RED

# Step 2: For every content slide, create and immediately set background:
slide_id = uid()
reqs.append(create_blank_slide(slide_id))   # 1. BLANK layout — never layoutId
reqs.append(slide_bg(slide_id, BG_PRIMARY)) # 2. ALWAYS set bg — prevents master bleedthrough
# 3. Add content shapes...
```

**For special slides that override the color (title, closing):**
```python
title_id = uid()
reqs.append(create_blank_slide(title_id))
reqs.append(slide_bg(title_id, ACCENT))   # Red override for title/closing only
```

The template master's background MUST NEVER control any slide's color.
The script owns every slide's background, always.

**NEVER use `layoutId` pointing to a named template layout** (Interior blank,
Interior agenda, Interior callout, etc.) — they carry inherited background
and positional constraints that break both color mode and margins. Use ONLY
`predefinedLayout: "BLANK"`.

This gives you a completely empty canvas with zero placeholder elements
and zero inherited constraints. Red Hat fonts are inherited from the master.

**Add branding manually to each slide:**

```python
def add_red_accent_bar(reqs, slide_id):
    """Red accent bar at top of content slides (full width, 0.06" tall)."""
    sid = uid()
    reqs.append({"createShape": {
        "objectId": sid,
        "shapeType": "RECTANGLE",
        "elementProperties": {
            "pageObjectId": slide_id,
            "size": {"width": {"magnitude": 9144000, "unit": "EMU"},
                     "height": {"magnitude": 54864, "unit": "EMU"}},
            "transform": {"scaleX": 1, "scaleY": 1,
                          "translateX": 0, "translateY": 0, "unit": "EMU"}
        }
    }})
    reqs.append({"updateShapeProperties": {
        "objectId": sid,
        "fields": "shapeBackgroundFill.solidFill.color",
        "shapeProperties": {"shapeBackgroundFill": {"solidFill": {
            "color": {"rgbColor": {"red": 0.933, "green": 0.0, "blue": 0.0}}
        }}}
    }})
    reqs.append({"updateShapeProperties": {
        "objectId": sid,
        "fields": "outline.outlineFill.solidFill.color,outline.weight",
        "shapeProperties": {"outline": {"outlineFill": {"solidFill": {
            "color": {"rgbColor": {"red": 0.933, "green": 0.0, "blue": 0.0}}
        }}, "weight": {"magnitude": 0, "unit": "EMU"}}}
    }})
```

**Do NOT use template content layouts** (Interior agenda, Interior callout,
Interior two column, etc.) — they contain placeholder elements that show
through as "Click to add subtitle" and cannot be reliably removed via the
API. Build all layouts from scratch with shapes and text boxes.

**IMPORTANT — Template does NOT support `predefinedLayout: "BLANK"`:**
The Red Hat template's master (`simple-light-2`) does NOT include `predefinedLayout: "BLANK"` as a valid layout. Using it will cause a 400 error. Therefore:

- Do NOT try to use `predefinedLayout: "BLANK"` on a presentation copied from the Red Hat template
- Instead, create a **fresh blank Google Slides presentation** using `gws slides presentations create`
- Red Hat Display and Red Hat Text are Google Fonts — they render correctly in any Slides file, so a fresh presentation is fully equivalent for branding purposes

**Always create a fresh blank presentation:**

```bash
gws slides presentations create --json '{"title": "Your Title Here"}' 2>&1 | \
  python3 -c "
import sys, json
lines = sys.stdin.readlines()
start = next(i for i, l in enumerate(lines) if l.strip().startswith('{'))
data = json.loads(''.join(lines[start:]))
print(data.get('presentationId'))
"
```

The default first slide has `objectId: "p"` — delete it after creating your first real slide.

### A3 — Write the Builder Script

Create a Python script at `<workspace>/slides-script/build_slides.py`.
**Copy `helpers.py` into the same directory** and import everything from it.

**⚠️ MANDATORY: Every build script MUST start with this exact template:**

```python
#!/usr/bin/env python3
"""Google Slides builder script — generated from presentation skill."""

import sys
import os

# Copy helpers.py to the same directory as this script, then import:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from helpers import *

# ---- Configuration ----
COLOR_MODE = "light"  # "light", "dark", or "expressive_dark"
setup_color_mode(COLOR_MODE)

# ---- Create presentation ----
PRES_ID = create_presentation("Your Title Here")

# ---- Build slides ----
reqs = []

# Slide 1: Title slide — MANDATORY build_title_slide() pattern, see below
s1 = uid()
reqs.append(create_slide(s1))
build_title_slide(reqs, s1, "Your Deck Title", "Optional deck subtitle",
                   presenter_name="Presenter Name", presenter_role="Title, Red Hat")

# Slide 2+: Content slides — ALWAYS use this pattern:
s2 = uid()
reqs.append(create_slide(s2))
reqs.append(slide_bg(s2, BG_PRIMARY))
add_red_accent_bar(reqs, s2)
add_slide_title(reqs, s2, "Your Slide Title")
add_slide_subtitle(reqs, s2, "Optional subtitle")
# ... add content using add_content_panel(), add_content_text(), etc. ...
add_rh_logo(reqs, s2, COLOR_MODE)
add_slide_number(reqs, s2, 2)

# Last slide: Thank You — MANDATORY build_thank_you_slide() pattern, see below
s_last = uid()
reqs.append(create_slide(s_last))
build_thank_you_slide(reqs, s_last, slide_num=3)

# Delete the default blank slide
reqs.append(delete_default_slide(PRES_ID))

# ---- Send ----
send_batch(PRES_ID, reqs)
print(f"https://docs.google.com/presentation/d/{PRES_ID}")
```

**DO NOT redefine ANY constants or functions that exist in helpers.py.**
The helpers module provides: all layout constants (SLIDE_W, CONTENT_TOP_Y,
COL2_W, etc.), all color constants (RH_RED, GRAY_95, etc.), all mandatory
functions (add_slide_title, add_content_panel, add_rh_logo, etc.), all
slide build patterns (build_two_column_slide, etc.), and the batch sender.

**DO NOT create shapes with raw coordinates.** Use the provided functions:
- `add_slide_title()` — title at correct position
- `add_slide_subtitle()` — subtitle at correct position
- `add_content_panel()` — background panel with boundary enforcement
- `add_content_text()` — text box with auto-height and boundary enforcement
- `add_red_accent_bar()` — accent bar at top
- `add_rh_logo()` — logo at bottom-right
- `add_slide_number()` — slide number at bottom-left
- `build_two_column_slide()` — complete two-column slide
- `build_three_column_slide()` — complete three-column slide
- `build_four_column_slide()` — complete four-column slide
- `build_card_grid_slide()` — complete 2×3 card grid slide
- `build_title_slide()` — complete first slide (MANDATORY, see below)
- `build_thank_you_slide()` — complete last slide (MANDATORY, see below)
- `build_agenda_slide()` — complete agenda slide with icon-or-badge items (MANDATORY icon lookup first, see A4b below)
- `build_split_image_slide()` — text bullets + image side-by-side (v2.0)
- `build_diagram_image_slide()` — embed Rich Draw.io v4 exported PNGs as full-width diagram slides (v2.0)
- `create_connector()` — connected arrow between two shapes (v2.0)
- `add_icon_overlay()` — place small Red Hat product icons on diagram slides (v3.0)

Key rules for the builder script:

- **Delete template sample slides** — remove the template's sample slides
  before adding your own (see A2). If using fallback blank mode, delete the
  default slide with `{"deleteObject": {"objectId": "p"}}`
- **Use BLANK slides only** — always create slides with
  `predefinedLayout: "BLANK"`. NEVER use `layoutId` pointing to any named
  template layout (even "Interior blank") — those carry inherited backgrounds
  and positional constraints that break color mode and margins. Only
  `predefinedLayout: "BLANK"` gives a true empty canvas.
- **Always set BG_PRIMARY immediately after create_blank_slide** — call
  `slide_bg(slide_id, BG_PRIMARY)` as the very next request after creating
  each slide. Without this, the template master's dark background bleeds
  through regardless of COLOR_MODE. Only override with a different color
  (e.g., ACCENT/red) on intentionally colored slides (title, closing).
- **Object IDs** — generate with `"e" + uuid.uuid4().hex[:10]` (must start with a letter)
- **Units** — all positions/sizes in EMU (1 inch = 914400 EMU, 1 pt = 12700 EMU)
- **Slide dimensions** — widescreen: 10" × 5.625" (9144000 × 5143500 EMU). This is the DEFAULT Google Slides size. Do NOT change the page size — all coordinates in this skill are calibrated for 10" × 5.625". NEVER use 13.33" × 7.5" coordinates
- **Empty text** — never call `insertText` with empty string, never call `updateTextStyle` on a shape with no text. Guard with `if text:` before text operations
- **Chunk size** — send batchUpdate in chunks of 200 requests max
- **Shape types** — use `TEXT_BOX` for text, `RECTANGLE` for boxes, `ROUND_RECTANGLE` for badges/buttons, `ELLIPSE` for dots
- **Text overflow prevention** — MANDATORY for every TEXT_BOX and RECTANGLE with text:

  1. **`shrinkTextOnOverflow: true`** — set on EVERY shape that contains text, no exceptions
  2. **Calculate height using `calc_box_height()`** — see MANDATORY Content Sizing Rules section below
  3. **Never hardcode text box heights** — always derive from content length and font size
  4. **Add autofit properties** after creating each text shape:

```python
def set_text_autofit(shape_id):
    """MUST be called for every shape that contains text."""
    return {"updateShapeProperties": {
        "objectId": shape_id,
        "fields": "autofit",
        "shapeProperties": {
            "autofit": {"autofitType": "TEXT_AUTOFIT"}
        }
    }}
```

  5. **Content budget per slide** — before building any slide, compute total content height:
     - Title: 0.50" at TITLE_Y (separate from content zone — handled by `add_slide_title()`)
     - Subtitle: 0.25" at SUBTITLE_Y (separate — handled by `add_slide_subtitle()`)
     - Content zone: 3.80" (CONTENT_TOP_Y=1.00" to CONTENT_BOT_Y=4.80")
     - Each bullet line: font_size_pt × 1.5 / 72 inches
     - Spacing between sections: 0.2"
     - Content MUST NOT exceed 3.80" total height
     - If total exceeds 3.80", split the slide into two slides BEFORE building

- **Red Hat logo on every content slide** — every content slide (not title, not Thank You) must have the Red Hat logo in the **bottom-right corner**, via `add_rh_logo(reqs, slide_id, color_mode)`. The logo is now a single, real wordmark IMAGE (hat icon + "Red Hat" text baked into one PNG, extracted from the official Red Hat Slides template) — two color variants (`RH_LOGO_URL_WHITE` / `RH_LOGO_URL_BLACK`) picked automatically by `color_mode`. Both are hosted on a plain public GitHub repo (`raw.githubusercontent.com`), which serves anonymous HTTPS GETs with no auth/token/expiry — this is what fixed the recurring "logo renders as plain text" defect, whose root cause was a tokenized/expiring `googleusercontent.com` URL (see Gotcha #3 below). `add_rh_logo()`, `add_rh_logo_image()`, and the constants (`RH_LOGO_URL_WHITE/BLACK`, `RH_LOGO_W/H/X/Y`) all live in `helpers.py` — do not redefine them in a build script.

**Logo placement rules:**
- Position: **bottom-right corner** of every content slide (`RH_LOGO_X/Y`, unchanged across redesigns so footer-zone math elsewhere never needs to move)
- Always call `add_rh_logo(reqs, slide_id, COLOR_MODE)` — it auto-selects white-text vs. black-text based on `color_mode`
- If `createImage` ever fails despite the stable URL, fall back to `add_rh_logo_text()` (native text, no image) rather than skipping the logo entirely
- Before EVER changing `RH_LOGO_URL_WHITE`/`RH_LOGO_URL_BLACK`, verify the new URL empirically first: `curl -s -o /dev/null -w "%{http_code}\n" <url>` must print `200` with NO auth headers attached (this is what Google's fetcher does) — do not trust a URL just because it opens in a logged-in browser
- The title slide and Thank You slide do NOT call `add_rh_logo()` — `build_title_slide()`/`build_thank_you_slide()` place the correctly-colored logo image internally

- **Title slide** (slide 1 of every deck) — MANDATORY: use `build_title_slide(reqs, slide_id, deck_title, deck_subtitle=None, presenter_name=None, presenter_role=None)` from `helpers.py`. NEVER hand-build this slide with `add_slide_title()`/`add_red_accent_bar()`/raw shapes — those are content-slide-only conventions and will not match the brand template. This function reproduces the official Red Hat title-slide template pixel-for-pixel (verified by rendering a real test presentation and comparing thumbnails):
  - Full-bleed two-tone illustration background (`TITLE_SLIDE_BG_URL`, via `slide_bg_image()`)
  - White decorative accent bar, bottom-left
  - Optional deck title (32pt bold) + subtitle (16pt) in the right-hand dark-red panel
  - Optional presenter name (16pt bold) + role (13pt regular) below the title
  - White wordmark logo, bottom-right (`RH_LOGO_URL_WHITE`)
  - Do NOT add a slide number to the title slide

- **Thank You slide** (last slide of every deck, NOT "closing slide") — MANDATORY: use `build_thank_you_slide(reqs, slide_id, slide_num, body_text=None, social_links=None)` from `helpers.py`. NEVER hand-build this slide. This function reproduces the official Red Hat closing-slide template pixel-for-pixel:
  - Full-bleed red (left ~62%) / white-footer (bottom strip) background (`CLOSING_SLIDE_BG_URL`, via `slide_bg_image()`)
  - Maroon decorative accent bars, top-left (tall) and bottom-left (short)
  - "Thank you" headline (40pt, white, Red Hat Display)
  - Body paragraph (12pt white) — defaults to standard Red Hat boilerplate if `body_text` omitted
  - Up to 4 social-link rows (icon + text), right side — defaults to Red Hat's official LinkedIn/YouTube/Facebook/Twitter if `social_links` omitted; pass your own `[(network, label), ...]` list (network must be one of the keys in `SOCIAL_ICON_URLS`) to customize
  - Slide number + black-text wordmark logo on the white footer band

Example:

```python
build_title_slide(reqs, s1, "SBI Shared File-System Analysis",
                   "GFS2 vs. IBM Storage Scale",
                   presenter_name="Nirjhar Jajodia",
                   presenter_role="Adoption Architect, Red Hat")
...
build_thank_you_slide(reqs, s_last, slide_num=len(all_slides))
```

### A4 — Execute

```bash
cd <workspace>/slides-script && python3 build_slides.py
```

The script calls `gws` as a subprocess. Verify all chunks succeed.
On error, read the error message (usually wrong objectId or text styling
on empty shape), fix the script, create a new presentation, and re-run.

If a run fails partway, do NOT retry on the same presentation — create a
fresh one and delete the broken one:

```bash
gws drive files delete --params '{"fileId": "<broken-id>"}'
```

### A4b — Add Icons

For any Agenda slide, icon lookup is MANDATORY-FIRST, not optional: before
writing the agenda slide's batchUpdate requests, you MUST search the Red
Hat Icon Repository for a matching icon per agenda item. Only fall back to
numbered circular badges (see the Agenda slide pattern below) if the
Icon Repository genuinely has no reasonable match after searching — never
default to badges/dots just because it's less work. This is the fix for
the recurring "agenda slides use red dots instead of icons" defect.

For every other icon use (product/tech accents, diagram labels, etc.),
sourcing from the Icon Repository remains optional/as-needed.

**Icon Repository ID:** `1SRhy8-bYBgaA3Jsi1t_Fxz-Yo9ORgdRy5Kec9hg_wSM`

See [references/icon-inventory.md](references/icon-inventory.md) for the
full inventory and extraction pattern.

To insert an icon into your presentation:

1. Get the icon's image contentUrl from the Icon Repository
2. Use `createImage` in your batchUpdate to place it on the target slide

```python
def add_icon(reqs, slide_id, image_url, left, top, width, height):
    """Insert an icon image from a URL."""
    sid = uid()
    reqs.append({"createImage": {
        "objectId": sid,
        "url": image_url,
        "elementProperties": {
            "pageObjectId": slide_id,
            "size": {
                "width": {"magnitude": width, "unit": "EMU"},
                "height": {"magnitude": height, "unit": "EMU"}
            },
            "transform": {
                "scaleX": 1, "scaleY": 1,
                "translateX": left, "translateY": top,
                "unit": "EMU"
            }
        }
    }})
    return sid
```

Use icons sparingly -- 1-2 per slide maximum. Best uses: stat slide accents,
feature list markers, architecture diagram labels, comparison column headers.

### A4b — Known API Gotchas (MUST READ)

These are confirmed issues with the Google Slides batchUpdate API that will
cause build failures if not handled:

1. **TEXT_AUTOFIT is not supported** — `updateShapeProperties` with
   `autofit.autofitType: "TEXT_AUTOFIT"` returns error "Autofit types other
   than NONE are not supported." The `set_text_autofit()` helper is a no-op.
   Overflow prevention relies entirely on `calc_box_height()` sizing. Keep
   text boxes generously sized (5-6 bullets max per box).

2. **Outline weight 0 is invalid** — `outline.weight: 0` returns error
   "should not be less than or equal to zero." To hide borders, use
   `shape_no_border()` which sets `outline.propertyState: "NOT_RENDERED"`.

3. **Drive/googleusercontent URLs fail or expire in createImage** — URLs like
   `https://lh3.googleusercontent.com/d/FILE_ID` or `drive.google.com/uc?export=download`
   require public sharing and still often fail with "Access to the provided
   image was forbidden," or work once and then break later. The `contentUrl`
   values extracted from existing Google Slides presentations (Icon Repository,
   Red Hat template) are Google-hosted, but they are tokenized and expire
   (documented ~30 min window) — fine for a one-off same-session insert, NOT
   safe to hardcode into `helpers.py` for reuse across builds. **Preferred
   fix: host the static asset yourself on a plain public GitHub repo and
   reference it via `raw.githubusercontent.com/<user>/<repo>/<branch>/<path>`.**
   That host serves anonymous HTTPS GETs with no auth/token/expiry — the
   exact conditions `createImage` needs. See `RH_LOGO_URL_WHITE`/`_BLACK`,
   `TITLE_SLIDE_BG_URL`, `CLOSING_SLIDE_BG_URL`, and `SOCIAL_ICON_URLS` in
   `helpers.py` for working examples, all hosted on the `nirjhar17/slide-assets`
   public repo. Whichever host you use, ALWAYS verify empirically before
   wiring a URL in: `curl -s -o /dev/null -w "%{http_code}\n" <url>` must
   print `200` with no auth headers attached — do not trust "it opens in
   my browser." This exact discipline (real `createImage` call + thumbnail
   render + pixel comparison against the reference template) was used to
   validate `build_title_slide()`/`build_thank_you_slide()` before they
   were adopted here.

4. **Icon search terms must match actual titles** — The Icon Repository icons
   have titles like `Technology_icon-Red_Hat-Ansible_Automation_Platform-Standard-RGB.png`,
   `Icon-Red_Hat-IT_modernization-Red-RGB.Large-icon.png`, etc. Search by these
   exact substrings, not generic terms like "clock" or "arrow."

### A5 — Deliver

Return the presentation URL to the user:
`https://docs.google.com/presentation/d/<PRES_ID>`

Remind them to:
- Add speaker notes if presenting live
- Review any sensitive names/data before sharing externally
- Logos are included via the template master slides; add product-specific
  logos manually if needed beyond what the template provides

---

## Approach B — Google Apps Script (Advanced / On Request)

Use only when the user explicitly requests Apps Script or needs advanced
features like inserting images from URLs. Requires first-time browser
authorization — not seamless from Cursor.

### B1 — Create an Apps Script Project

```bash
gws script projects create --json '{"title": "slide-builder"}' 2>&1 | \
  python3 -c "
import sys, json
lines = sys.stdin.readlines()
start = next(i for i, l in enumerate(lines) if l.strip().startswith('{'))
data = json.loads(''.join(lines[start:]))
print(data.get('scriptId'))
"
```

### B2 — Write Code.gs

Apps Script uses `SlidesApp` — simpler code, positions in points (not EMU),
chainable styling. Can insert images via `slide.insertImage(url)`.

### B3 — Push and Execute

```bash
gws script projects updateContent --params '{"scriptId": "<SCRIPT_ID>"}' \
  --json '{"files": [{"name":"Code","type":"SERVER_JS","source":"<code>"}]}'
```

First run requires browser authorization at:
`https://script.google.com/d/<SCRIPT_ID>/edit`

### B4 — Deliver

Return the presentation URL from the script output.

## Design System

### Color Mode

Before generating any slides, **always ask the user** which color mode they prefer. Do not assume a default — wait for their answer:

- **Light mode** — clean white backgrounds, dark text, best for print and email sharing
- **Dark mode** — cinematic dark backgrounds, white text, best for presenting on screen
- **Expressive Dark mode** — purple/teal-accented dark backgrounds, more colorful and energetic, best for creative or forward-looking topics

Set a `COLOR_MODE` variable at the top of the builder script. All slide
background, text, and accent colors derive from this choice.

### Color Palette (Red Hat Brand)

**Core colors** (used in both modes):

```python
# Brand red
RH_RED         = {"red": 0.933, "green": 0.0,   "blue": 0.0}    # #ee0000 red-50
RH_RED_DARK    = {"red": 0.651, "green": 0.0,   "blue": 0.0}    # #a60000 red-60
RH_RED_LIGHT   = {"red": 0.961, "green": 0.431, "blue": 0.431}  # #f56e6e red-40
RH_RED_TINT    = {"red": 0.988, "green": 0.890, "blue": 0.890}  # #fce3e3 red-10

# Grays
RH_DARK        = {"red": 0.102, "green": 0.102, "blue": 0.102}  # #1a1a1a
GRAY_95        = {"red": 0.082, "green": 0.082, "blue": 0.082}  # #151515
GRAY_90        = {"red": 0.122, "green": 0.122, "blue": 0.122}  # #1f1f1f
GRAY_80        = {"red": 0.161, "green": 0.161, "blue": 0.161}  # #292929
GRAY_60        = {"red": 0.302, "green": 0.302, "blue": 0.302}  # #4d4d4d
RH_GRAY        = {"red": 0.29,  "green": 0.29,  "blue": 0.29}   # #4a4a4a
GRAY_40        = {"red": 0.639, "green": 0.639, "blue": 0.639}  # #a3a3a3
GRAY_20        = {"red": 0.878, "green": 0.878, "blue": 0.878}  # #e0e0e0
GRAY_10        = {"red": 0.949, "green": 0.949, "blue": 0.949}  # #f2f2f2
RH_LIGHT_GRAY  = {"red": 0.96,  "green": 0.96,  "blue": 0.96}   # #f5f5f5
WHITE          = {"red": 1.0,   "green": 1.0,   "blue": 1.0}    # #ffffff

# Teal
TEAL_10        = {"red": 0.855, "green": 0.949, "blue": 0.949}  # #daf2f2
TEAL_40        = {"red": 0.388, "green": 0.741, "blue": 0.741}  # #63bdbd
TEAL_50        = {"red": 0.216, "green": 0.639, "blue": 0.639}  # #37a3a3
TEAL_60        = {"red": 0.078, "green": 0.471, "blue": 0.471}  # #147878

# Purple
PURPLE_10      = {"red": 0.925, "green": 0.902, "blue": 1.0}    # #ece6ff
PURPLE_40      = {"red": 0.529, "green": 0.435, "blue": 0.831}  # #876fd4
PURPLE_50      = {"red": 0.369, "green": 0.251, "blue": 0.745}  # #5e40be
PURPLE_60      = {"red": 0.239, "green": 0.153, "blue": 0.522}  # #3d2785

# Orange
ORANGE_10      = {"red": 1.0,   "green": 0.910, "blue": 0.800}  # #ffe8cc
ORANGE_40      = {"red": 0.961, "green": 0.573, "blue": 0.106}  # #f5921b
ORANGE_50      = {"red": 0.792, "green": 0.424, "blue": 0.059}  # #ca6c0f
ORANGE_60      = {"red": 0.620, "green": 0.290, "blue": 0.024}  # #9e4a06

# Yellow
YELLOW_10      = {"red": 1.0,   "green": 0.957, "blue": 0.800}  # #fff4cc
YELLOW_40      = {"red": 0.863, "green": 0.651, "blue": 0.078}  # #dca614
YELLOW_50      = {"red": 0.725, "green": 0.518, "blue": 0.071}  # #b98412
YELLOW_60      = {"red": 0.588, "green": 0.392, "blue": 0.059}  # #96640f

# Utility
RH_BLUE        = {"red": 0.0,   "green": 0.4,   "blue": 0.8}    # #0066cc
RH_GREEN       = {"red": 0.243, "green": 0.525, "blue": 0.208}  # #3e8635
```

**Dark mode palette** (when dark mode chosen):

```python
BG_PRIMARY     = GRAY_95       # slide backgrounds
BG_SECONDARY   = GRAY_80       # alternate/card backgrounds
TEXT_PRIMARY   = WHITE          # main text
TEXT_SECONDARY = GRAY_40        # subtitles, descriptions
TEXT_MUTED     = GRAY_60        # footers, references
ACCENT         = RH_RED         # highlights, accent bars
```

**Light mode palette**:

```python
BG_PRIMARY     = WHITE          # slide backgrounds
BG_SECONDARY   = GRAY_10        # alternate/card backgrounds
TEXT_PRIMARY   = GRAY_95         # main text
TEXT_SECONDARY = GRAY_60         # subtitles, descriptions
TEXT_MUTED     = RH_GRAY         # footers, references
ACCENT         = RH_RED          # highlights, accent bars
```

**Expressive Dark mode palette** (purple/teal accents for creative decks):

```python
PURPLE_80      = {"red": 0.106, "green": 0.051, "blue": 0.200}  # #1b0d33
PURPLE_70      = {"red": 0.129, "green": 0.075, "blue": 0.302}  # #21134d
BLACK          = {"red": 0.0,   "green": 0.0,   "blue": 0.0}    # #000000
PURPLE_20      = {"red": 0.816, "green": 0.773, "blue": 0.957}  # #d0c5f4
PURPLE_30      = {"red": 0.714, "green": 0.651, "blue": 0.914}  # #b6a6e9

BG_PRIMARY     = PURPLE_80      # slide backgrounds
BG_SECONDARY   = BLACK          # alternate/card backgrounds
BG_SURFACE     = PURPLE_70      # card/panel backgrounds
TEXT_PRIMARY   = WHITE           # main text
TEXT_SECONDARY = PURPLE_20       # subtitles, descriptions
TEXT_MUTED     = PURPLE_30       # footers, references
ACCENT         = RH_RED          # highlights, accent bars
HIGHLIGHT_TEAL = TEAL_50         # secondary accent for data, positive indicators
HIGHLIGHT_PURPLE = PURPLE_40     # tertiary accent for tags, categories
```

Use Expressive Dark for topics like AI, innovation, future strategy, or
creative workshops where a more energetic visual tone is appropriate.
Standard Dark mode remains the default for most corporate presentations.

If the user specifies a different brand, derive a palette from their brand
colors using the same light-tint / dark-shade pattern above.

### Slide Patterns

Use these proven patterns for professional layouts. Every pattern is built
entirely from shapes and text boxes on a BLANK slide — never rely on
template layout placeholders.

- **Title slide** — MANDATORY: use `build_title_slide()` from `helpers.py` (see A3 above). Do NOT hand-build this slide.
- **Agenda slide** — MANDATORY: use `build_agenda_slide()` from `helpers.py`. Icon lookup is MANDATORY-FIRST (see A4b): search the Icon Repository for EVERY agenda item BEFORE calling this function, and pass each resolved `contentUrl` as that item's `icon_url`. **NEVER mix icons and numbered badges on the same slide** — `build_agenda_slide()` enforces this: if even one item is missing `icon_url`, it silently forces ALL items to numbered badges instead of rendering a mixed slide (verified — see below). This means the real work is upstream: keep searching until every item has a reasonable icon, including approximate/metaphorical matches (e.g. a connectivity/link icon for a cross-site replication topic, a generic info icon for a scope/requirements item) — don't settle for `None` just because there's no exact-name match. Only fall back to a fully-badged slide when the topic set genuinely has no reasonable icon coverage at all. Verified by rendering two real test agendas: a mixed one (confirmed broken/inconsistent-looking) and the corrected all-icons version where every one of 4 items — including two initially-"no match" topics — got a real, on-topic icon:

```python
items = [
    {"title": "Requirements & Constraints", "description": "...", "icon_url": INFO_ICON_URL},
    {"title": "Red Hat GFS2 Architecture", "description": "...", "icon_url": RHEL_ICON_URL},
    {"title": "IBM Storage Scale (GPFS)", "description": "...", "icon_url": CONNECTIVITY_ICON_URL},  # metaphorical match: cross-site link
    {"title": "Backup, DR & Support Boundaries", "description": "...", "icon_url": BACKUP_ICON_URL},
]
build_agenda_slide(reqs, slide_id, "Agenda", "What we will cover today", items, slide_num=2)
```

Row slots subdivide the content zone evenly so any item count (2-8) fits without overflow.
- **Comparison / two-column** — colored header rectangles (one per column, e.g., green vs orange), bullet points below each header as text boxes, background panel rectangles behind each column in pastel tint
- **Card grid** — 2×2 layout with colored header bars and white body with border
- **Timeline (horizontal)** — horizontal gray line as axis, colored circles (dots) at each milestone, pastel-filled rectangles above the line for version info, text labels for dates below. Use distinct colors per status (green=active, orange=warning, red=EOL)
- **Split columns** — left problem / right solution, colored headers
- **Metric callouts** — rounded-rect with large number + label beside it
- **FROM → TO table** — alternating rows, red tint for "from", green tint for "to"
- **Thank You slide** (last slide) — MANDATORY: use `build_thank_you_slide()` from `helpers.py` (see A3 above). Do NOT hand-build this slide.
- **Quote slide** — large pull quote (18-22pt Red Hat Display, light weight), attribution below in smaller Red Hat Text, red accent bar on left edge, BG_SECONDARY background
- **Big Number / Stat slide** — one giant number (36-48pt Red Hat Display Bold, ACCENT color), context label below (10-12pt), optional delta indicator (arrow or +/- in TEAL_50 or RH_RED)
- **Flowchart / Decision slide** — dark rectangle for decision question at top, colored rectangles for options below, small arrow shapes (triangle/rectangle) connecting elements vertically, 3 destination boxes at bottom
- **Upgrade/Process Path diagram** — colored background panels per path (pastel), white boxes for each state/step, small filled rectangles as directional arrows between boxes, label text below or beside each path, "RECOMMENDED" badge (green rounded-rect) on preferred path

### Additional Visual Patterns

#### Pattern: Split-Layout with Image (text + illustration)

Use for concept slides where one half explains in bullets and the other
half shows an AI-generated illustration, photo, or screenshot.

```python
s = uid()
reqs.append(create_slide(s))
build_split_image_slide(reqs, s,
    title="Prefill Is Compute, Decode Is Memory",
    subtitle=None,
    bullets=[
        "PREFILL: processes entire input in parallel on GPU",
        "Produces the first token (TTFT metric)",
        "DECODE: generates tokens one at a time",
        "Memory-bound — limited by KV cache access speed",
    ],
    image_url="https://example.com/illustration.png",
    slide_num=4,
    color_mode=COLOR_MODE,
    image_side="right")   # or "left"
```

Image must be a publicly-fetchable URL (raw.githubusercontent.com is best).
The image auto-centers vertically in the content zone and respects the
footer boundary.

#### Pattern: Diagram Image Slide (Rich Draw.io v4 exported PNGs)

Use for embedding Rich Draw.io v4 exported PNG diagrams that should fill
most of the content zone. See "Diagram Approach: Rich Draw.io v4" below
for how to create the source .drawio files.

```python
s = uid()
reqs.append(create_slide(s))
build_diagram_image_slide(reqs, s,
    title="KServe Separates Runtime from Model",
    subtitle="Platform teams own ServingRuntime, data scientists own InferenceService",
    image_url="https://example.com/kserve-sketch.png",
    slide_num=5,
    color_mode=COLOR_MODE,
    image_scale=0.85)   # 0.0-1.0, fraction of content zone
```

For draw.io sketch diagrams, export at 2x scale for crisp display. Use
`sketch=1, curveFitting=1, jiggle=2` in draw.io XML for hand-drawn style.
Set `fontColor=#FFFFFF` on edge labels when using dark backgrounds.

#### Pattern: Connected Connectors (node-to-node arrows)

Use `create_connector()` to draw real connected lines between shapes.
Connection site indices: 0=top, 1=right, 2=bottom, 3=left.

```python
box_a = add_rounded_rect(reqs, slide_id, "Step A", ...)
box_b = add_rounded_rect(reqs, slide_id, "Step B", ...)
create_connector(reqs, slide_id, box_a, box_b,
    start_site=2, end_site=0,      # bottom of A → top of B
    end_arrow="OPEN_ARROW",
    line_color=TEXT_MUTED,
    weight_pt=1.5)
```

Arrow styles: `"NONE"`, `"OPEN_ARROW"`, `"FILL_ARROW"`,
`"FILL_CIRCLE"`, `"FILL_SQUARE"`, `"FILL_DIAMOND"`.

### Diagram Approach: Rich Draw.io v4

All architecture/flow/pipeline diagrams use hand-crafted draw.io XML exported as high-res PNG images. This is the ONLY approved diagram approach — do NOT use native Slides API shapes for diagrams.

#### When to Use a Diagram
- Architecture showing components and their relationships
- Pipeline/flow showing data transformation steps
- Any slide that would benefit from visual boxes + arrows

#### When NOT to Use a Diagram (use other slide types instead)
- Concept explanations → use build_split_image_slide() with AI illustration
- Feature comparisons → use build_two_column_slide() or build_card_slide()
- Simple lists → use build_bullet_slide()

#### Diagram Style Rules

1. Dark purple background: Set `background="#1b0d33"` in the mxGraphModel
2. Use proper industry shapes based on component type:
   - Databases/storage: `shape=cylinder3` (cylinder)
   - Cloud services: `shape=cloud`
   - Documents/files: `shape=document`
   - Processing/transforms: `shape=process` (box with side bars)
   - Users/clients: `ellipse` (circle/oval)
   - K8s resources: `rounded=1` rectangle with badge
   - Containers/boundaries: dashed border container (`dashed=1;dashPattern=5 5;verticalAlign=top;`)
3. Color palette:
   - Teal (#37a3a3, #009DA5) for primary/data components
   - Red (#EE0000) for critical/accent (model servers, first token)
   - Purple (#7B2D8E, #21134d) for secondary/infrastructure
   - Orange (#EC7A08) for optional/alternate paths
   - Teal-dark (#147878) for badges
4. ALL text: `fontColor=#FFFFFF`
5. ALL edge labels: `fontColor=#FFFFFF;labelBackgroundColor=#1b0d33`
6. ALL arrows: `strokeColor=#63bdbd;strokeWidth=2` or `strokeColor=#FFFFFF;strokeWidth=2`
7. Font sizes: 14-18px for node labels, 10-12px for edge labels, 18-24px for section titles
8. Use nested containers for grouping (e.g., "Kubernetes Cluster", "Prefill Phase", "Query Pipeline")
9. Use badges for annotations (e.g., "Compute-Bound", "Batch Ingestion", "Always-On")
10. Page size: 1600x900 for proper aspect ratio

#### Export and Embed

```bash
# Export at 3x scale for crisp display
/Applications/draw.io.app/Contents/MacOS/draw.io --export --format png --scale 3 --output diagram.png diagram.drawio
```

Then embed using build_diagram_image_slide() in helpers.py.

#### Red Hat Product Icon Overlay

After embedding the diagram PNG, overlay official Red Hat product icons from the Icon Repository (presentation ID: 1SRhy8-bYBgaA3Jsi1t_Fxz-Yo9ORgdRy5Kec9hg_wSM) on relevant components:
- OpenShift icon on K8s boxes
- AI Model icon on ML model boxes
- Private Cloud icon on storage boxes
- AI Inference icon on inference/LLM boxes

Use the `add_icon_overlay()` helper from helpers.py to place icons as 32pt × 32pt separate createImage elements on the slide.

#### Reference Diagrams

See these .drawio files in `diagram-templates/` as templates for the approved quality level:
- `inference-pipeline.drawio` — two-phase inference with nested containers, badges, KV cache visualization
- `test-rag-industry-colored.drawio` — RAG pipeline with industry shapes and two-lane layout
- `test-mixed-kserve.drawio` — KServe architecture with nested K8s boundary

### Typography

Always use the official Red Hat font families (available in Google Slides via Google Fonts).
Do not use Arial — Red Hat fonts are always available in Google Slides.

- **Red Hat Display** — slide titles, headlines, large impact text (bold, expressive)
- **Red Hat Text** — body copy, descriptions, bullet points (readable at small sizes)
- **Red Hat Mono** — code snippets, technical labels, tags

| Element        | Font Family      | Font Size | Weight | Color          |
|---------------|-----------------|-----------|--------|----------------|
| Slide title  

…(truncated)
