# Pptlive

> LLM-friendly API/CLI for driving powerpoint.

- **Type:** MCP server
- **Install:** `agentstack add mcp-thomas-villani-pptlive`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [thomas-villani](https://agentstack.voostack.com/s/thomas-villani)
- **Installs:** 0
- **Category:** [AI & ML](https://agentstack.voostack.com/c/ai-and-ml)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [thomas-villani](https://github.com/thomas-villani)
- **Source:** https://github.com/thomas-villani/pptlive
- **Website:** https://thomas-villani.github.io/pptlive/

## Install

```sh
agentstack add mcp-thomas-villani-pptlive
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# pptlive

[](https://pypi.org/project/pptlive/)
[](https://pypi.org/project/pptlive/)
[](https://github.com/thomas-villani/pptlive/blob/main/LICENSE)
[](https://thomas-villani.github.io/pptlive/)

Drive a running Microsoft PowerPoint instance from Python — `xlwings`, but for
PowerPoint. Built for both human scripting and LLM agents. Windows-only.

📖 **[Documentation](https://thomas-villani.github.io/pptlive/)** — full Python API, CLI, MCP server, and cookbook.

The live-app sibling of [`python-pptx`](https://python-pptx.readthedocs.io/)
(which works the `.pptx` on disk) and the PowerPoint counterpart of
[`wordlive`](https://github.com/thomas-villani/wordlive). Use it when the user already has the deck open and you
want to edit it *live* — no close-the-file, let-the-agent-write, re-open dance.

| Library     | Target                    | Mechanism                |
| ----------- | ------------------------- | ------------------------ |
| python-pptx | a `.pptx` file on disk    | OOXML I/O                |
| **pptlive** | **a running POWERPNT.exe**| **COM automation (pywin32)** |

## Install

```
pip install pptlive

# with the MCP server for Claude Desktop & other MCP agents (see "MCP server")
pip install "pptlive[mcp]"

# add to a project
uv add pptlive
```

(Requires Python 3.11+ and `pywin32` on Windows.)

## Python

```python
import pptlive as pl

with pl.attach() as ppt:
    deck = ppt.presentations.active

    # Reads (structured, side-effect-free)
    slides  = deck.slides.list()        # [{index, id, layout, title, shape_count, has_notes}]
    outline = deck.outline()            # [{slide, title, bullets:[...]}]
    grid    = deck.slides[2].read()     # every shape: anchor_id, name, id, type, geometry, text
    title   = deck.slides[2].placeholder("title").text
    notes   = deck.slides[1].notes.text

    # Polite writes — preserve the user's viewed slide + Selection.
    with deck.edit("Revise the agenda slide"):
        deck.anchor_by_id("ph:2:title").set_text("Agenda")
        deck.anchor_by_id("ph:2:body").set_text("Intro\nDemo\nQ&A")

    # Slide lifecycle — also one Ctrl-Z per edit() block.
    names = deck.layouts()                   # [{index, name}] — what set_layout/add accept
    with deck.edit("Add a results slide"):
        new = deck.slides.add(layout="two_content", index=4)
        deck.slides[7].duplicate()           # copy lands at slide 8
        deck.slides[9].move_to(2)
        deck.slides[4].set_layout("title_and_content")

    # Shapes & geometry — points throughout (pl.units for inches/cm).
    with deck.edit("Lay out the results slide"):
        shapes = deck.slides[4].shapes
        shapes.add_textbox("Revenue up 12%", left=pl.units.inches(1), top=72)
        star = shapes.add_shape("star", left=400, top=120, width=120, height=120,
                                fill="#C00000", line="none")     # fill / border on creation
        logo = shapes.add_picture("logo.png", left=600, top=40,   # embedded, never linked
                                  alt_text="Acme logo")           # a drift-proof re-id handle
        deck.slides[4].shapes["Picture 3"].move(top=140)   # absolute, points
        star.set_fill(fill="#1F1F1F", line="#FFFFFF", line_width=2)  # fill ≠ font color
        star.reorder("front")                              # z-order: front/back/forward/backward
        star.delete()

    # Pictures — alt text doubles as a re-identification handle; export one shape for vision.
    logo.set_alt_text("Acme logo (top-right)")           # survives z-order drift
    chart_png = deck.slides[4].shapes["Chart 2"].export_image()   # just that shape, native size

    # Charts — a chart is a shape; its data lives in an embedded Excel workbook.
    with deck.edit("Add a revenue chart"):
        chart = deck.slides[4].shapes.add_chart(
            "column", ["Q1", "Q2", "Q3"], {"Revenue": [10, 20, 30], "Profit": [3, 6, 9]}
        ).chart
        chart.set_type("line")                           # change the kind
        chart.recolor_text("#FFFFFF")                    # all chart text white (dark-theme fix)
    data = chart.read()                                  # {chart_type, categories, series:[...]}

    # SmartArt — a diagram is a shape too; its content is a node tree.
    with deck.edit("Add a process diagram"):
        sa = deck.slides[3].shapes.add_smartart(
            "process", ["Discover", "Design", "Build", "Ship"]   # flat list…
        ).smartart
        sa.set_nodes([{"text": "CEO", "children": ["VP Eng", "VP Sales"]}])  # …or a tree
        sa.recolor_text("#FFFFFF")                       # every node label white
    tree = sa.read()                                     # {layout, nodes:[{text, level, children}]}

    # Text structure — paragraphs, formatting, bullets. (Per-anchor formatting;
    # for deck-wide styling use deck.theme / deck.master below.)
    with deck.edit("Polish the body copy"):
        body = deck.anchor_by_id("ph:4:body")
        body.set_text("Revenue up 12%\nChurn down 3%\nNPS +9")
        body.apply_list("bulleted")                 # bullets on every paragraph
        body.paragraph(2).format_paragraph(indent_level=2, alignment="left")
        body.paragraph(1).format_text(bold=True, size=24, color="#2E74B5")
        body.insert_paragraph_after("Cash runway: 30 months")   # append a bullet

    # Tables — a table is a shape (Shape.has_table); cells are cell:S:N:R:C anchors.
    with deck.edit("Add a metrics table"):
        table = deck.slides[4].shapes.add_table(rows=3, columns=2).table
        table.cell(1, 1).set_text("Metric")
        table.cell(1, 2).set_text("Q3")
        table.add_row(["Revenue", "$4.2M"])          # appends + fills a row
        deck.anchor_by_id("cell:4:5:1:1").format_text(bold=True)   # a Cell is an anchor
    grid = table.read()                              # {slide, shape, rows, columns, cells:[...]}

    # Deck-wide styling — theme (palette + fonts) and master (text styles +
    # background) restyle every inheriting slide at once. Global + anti-polite,
    # but still one Ctrl-Z; your view doesn't move.
    with deck.edit("Rebrand the deck"):
        deck.theme.set_color("accent1", "#C00000")       # recolor the whole deck
        deck.theme.set_font("major", "Georgia")          # major = headings, minor = body
        deck.master.format_text_style("body", 1, font="Georgia", size=28)
        deck.master.set_background("#1F1F1F")            # solid fill
    palette = deck.theme.read()                          # {colors:{slot:#RRGGBB}, fonts:{major, minor}}

    # Find / replace — fuzzy traversal across shapes, table cells, and notes
    # (no deck-wide character stream, so this is a walk, not a range).
    hits = deck.find("Q3 Reuslts")                       # [{anchor_id, start, length, text, ...}]
    with deck.edit("Fix the typo everywhere"):
        deck.find_replace("Q3 Reuslts", "Q3 Results", all=True)

    # Review comments — slide-anchored at an (x, y) point, and threaded.
    review = deck.slides[2].comments                     # per-slide CommentCollection
    with deck.edit("Leave review notes"):
        c = review.add("Tighten this headline", left=100, top=80)
        c.reply("Agreed — will do")
    roll = deck.comments()                               # deck-wide roll-up {total, slides:[...]}

    # Whole-deck snapshot — one low-res PNG per slide so a vision model can SEE
    # the whole deck cheaply (max_dim caps each slide's long edge). A read — polite.
    snaps = deck.snapshot(max_dim=1000)                  # [Snapshot(slide, image, path), ...]

    # Output tier — explicit, never implicit (pptlive never auto-saves).
    deck.save()                                          # persist to the existing file
    deck.save_as("v2.pptx", overwrite=True)              # write + rebind the working file
    deck.export_pdf("deck.pdf")                          # a read: no rebind, dirty flag kept

    # Media + narrated-video export — build a deck, narrate it, export an MP4.
    with deck.edit("Narrate the deck"):
        deck.slides[1].add_audio("intro.mp3")            # embed; autoplay + pace the slide
        deck.slides[2].add_video("demo.mp4")             # a video clip (stays visible)
    result = deck.export_video("deck.mp4", resolution=1080)   # async CreateVideo; blocks to done
    assert result.ok and result.status == "done"             # result.path is the MP4

    # Render — let a vision model *see* the slide it just built (export → read → iterate).
    png = deck.slides[4].export_image(width=1280)    # temp PNG (or pass a path); polite
    #   ...hand `png` to your image tool, look, then revise.

    # Read what the user is looking at, and (opt-in) target it with the here: anchor.
    sel = deck.selection()                           # {type, slide, anchor_id, shapes, ...}
    if sel.anchor_id:
        with deck.edit("Bold the selected text"):
            deck.anchor_by_id("here:").format_text(bold=True)

    # Live slide show — drive the presentation like a clicker (deliberately moves the screen).
    deck.show.start()                                # run from the top
    deck.show.goto(5); deck.show.next(); deck.show.black()   # jump, advance, blank
    deck.show.state()                                # {running, state, current_slide, ...}
    deck.show.end()
```

## Anchors

Addressing is **hierarchical** (slide → shape → text), not a global character
stream — there is no deck-wide `range:`. Anchor ids are colon-separated,
slide-index first:

| anchor_id      | resolves to |
| -------------- | ----------- |
| `shape:S:N`    | Nth shape (1-based z-order) on slide S — the canonical handle |
| `shapeid:S:ID` | shape with stable `Shape.Id` ID on slide S — the **delete-proof** handle (survives a delete/restack that shifts `shape:S:N`) |
| `ph:S:KIND`    | placeholder of semantic KIND (`title`/`ctrtitle`/`subtitle`/`body`/`footer`/`date`/`slidenum`) — the LLM-preferred form |
| `para:S:N:P`   | paragraph P (1-based) of shape N on slide S |
| `cell:S:N:R:C` | cell (row R, col C) of the table in shape N on slide S — a `Cell` *is* an anchor, so it takes every text/format verb |
| `notes:S`      | speaker-notes body of slide S |
| `comments:S`   | the review comments on slide S — a read selector (a container, addressed for reply/delete by `(slide, 1-based index)`) |
| `here:`        | whatever the user has selected right now — the shape, or the paragraph holding the text caret (the opt-in way to act on the live selection) |

z-order **drifts** when shapes are added or removed (or restacked via
`Shape.reorder`), so `shape:S:N` is resolved live and never cached; every shape
listing also emits `name` (`Shape.Name`) and `id` (`Shape.Id`, stable across
reorder). The drift-proof forms are `ph:S:KIND`, `.Name`, and `shapeid:S:ID`.
`para:S:N:P` and `cell:S:N:R:C` also resolve live (the paragraph/row count shifts
as text or rows are inserted/deleted).

## CLI

JSON in, JSON out, deterministic exit codes — designed to drop straight into an
LLM tool-use loop. Global flags (`--json`/`--text`, `--doc NAME`) go *before* the
subcommand.

```
pptlive status                                   # open decks, active one, slide in view
pptlive slides                                   # [{index, id, layout, title, shape_count, has_notes}]
pptlive outline                                  # title + body bullets per slide
pptlive slide read 2                             # every shape on slide 2
pptlive shapes --slide 2                         # shapes on slide 2 (anchor_id, name, id, type, geometry)

pptlive read anchor --anchor-id ph:2:title       # read any text anchor (ph:/shape:/notes:)
pptlive read notes --slide 1                     # sugar for --anchor-id notes:1
pptlive write   --anchor-id ph:2:body  --text "Intro\nDemo\nQ&A"
pptlive replace --anchor-id shape:3:1  --text "New text"
pptlive find    --text "Q3 Reuslts" [--in slide:2]          # fuzzy locate across shapes/cells/notes
pptlive replace --find "Q3 Reuslts" --text "Q3 Results" --all  # fuzzy replace (or --occurrence N)

pptlive slide layouts                            # the layout names add/set-layout accept
pptlive slide add --layout two_content [--index 4]
pptlive slide duplicate --slide 7
pptlive slide move --slide 9 --to 2
pptlive slide set-layout --slide 4 --layout title_and_content
pptlive slide delete --slide 5
pptlive slide export --slide 2 --out slide2.png [--width 1280] [--format png]  # render to image
pptlive slide geometry 2                         # slide size + shape boxes + overlaps + off-slide (no render)

pptlive shape add --slide 4 --kind textbox --text "Revenue up 12%" --left 72 --top 72
pptlive shape add --slide 4 --kind shape --shape-type star --left 400 --top 120 --width 120 --height 120
pptlive shape add --slide 4 --kind picture --path logo.png --left 600 --top 40 --alt-text "Acme logo"
pptlive shape add --slide 4 --kind table --rows 3 --cols 2 --left 72 --top 120
pptlive shape move   --anchor-id shape:4:3 --left 100 --top 140
pptlive shape resize --anchor-id shape:4:3 --width 300 --height 200
pptlive shape delete --anchor-id shape:4:3
pptlive shape set-alt --anchor-id shape:4:3 --alt-text "Acme logo"      # drift-proof re-id handle
pptlive shape fill    --anchor-id shape:4:3 --fill "#C00000" --line none --line-width 2  # fill/border
pptlive shape order   --anchor-id shape:4:3 --to front       # z-order: front/back/forward/backward
pptlive shape export  --anchor-id shape:4:3 --out logo.png   # render one shape (native size)
pptlive shape animate --anchor-id shape:4:3 --effect fly_in [--trigger after_previous] [--exit]
pptlive slide animations 4                       # a slide's shape animations in play order

pptlive shape add --slide 4 --kind chart --chart-type column \
    --categories "Q1,Q2,Q3" --series '{"Revenue":[10,20,30]}'
pptlive chart read     --slide 4 --shape 5                    # {chart_type, categories, series}
pptlive chart set-type --slide 4 --shape 5 --chart-type line
pptlive chart set-data --slide 4 --shape 5 --categories "A,B" --series '{"S":[1,2]}'
pptlive chart recolor-text --slide 4 --shape 5 --color "#FFFFFF"   # all chart text (dark-theme fix)

pptlive shape add --slide 3 --kind smartart --smartart-kind process \
    --nodes '["Discover","Design","Build","Ship"]'           # flat list or {text,children} tree
pptlive smartart read      --slide 3 --shape 2               # {layout, nodes:[{text, level, children}]}
pptlive smartart set-nodes --slide 3 --shape 2 --nodes '[{"text":"CEO","children":["Eng","Sales"]}]'
pptlive smartart recolor-text --slide 3 --shape 2 --color "#FFFFFF"   # every node label

pptlive theme  read                              # deck palette (12 slots) + heading/body fonts
pptlive theme  set-color --slot accent1 --color "#C00000"    # recolors the whole deck
pptlive theme  set-font  --which major --name "Georgia"      # major = headings, minor = body
pptlive master read                              # master text styles (title/body/default) + background
pptlive master format-text-style --style body --level 1 --font "Georgia" --size 28
pptlive master set-background --color "#1F1F1F"  # deck-wide; solid fill

pptlive paragraphs --anchor-id ph:4:body         # [{anchor_id (para:S:N:P), text, indent_level, bullet}]
pptlive insert --anchor-id para:4:2:3 --text "New bullet" [--before|--after]
pptlive format-paragraph --anchor-id para:4:2:1 --alignment center --indent-level 2
pptlive format-text --anchor-id ph:4:title --bold --size 40 --color "#2E74B5"
pptlive list apply  --anchor-id ph:4:body --type bulleted [--char "•"]
pptlive list remove --anchor-id ph:4:body

pptlive table read --slide 4 --shape 5           # grid of cells, each with its cell:S:N:R:C anchor
pptlive table add-row    --slide 4 --shape 5 --values '["Revenue", "$4.2M"]'
pptlive table delete-row --slide 4 --shape 5 --row 2
pptlive write --anchor-id cell:4:5:1:1 --text "Metric"   # a cell takes write/format-text/...

pptli

…

## Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [thomas-villani](https://github.com/thomas-villani)
- **Source:** [thomas-villani/pptlive](https://github.com/thomas-villani/pptlive)
- **License:** MIT
- **Homepage:** https://thomas-villani.github.io/pptlive/

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-thomas-villani-pptlive
- Seller: https://agentstack.voostack.com/s/thomas-villani
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
