Skip to content

CLI

The pptlive command is a thin, JSON-first wrapper over the Python API. Every invocation prints one JSON object on stdout (logs and errors go to stderr) and exits with a deterministic code, so it drops straight into an LLM tool-use loop. It's the same politeness and one-Ctrl-Z atomic undo as the library.

pptlive status
pptlive --text slides              # human-readable instead of JSON
pptlive --doc Pitch.pptx outline   # target a specific open deck

Global flags

Global flags go before the subcommand:

Flag Default Meaning
--json / --text --json Output format. --json prints one object per call; --text prints a human-readable rendering.
--doc NAME active deck Target an open presentation by name instead of the active one.
-h / --help Show help for the command or group.

Exit codes

Exit Meaning Retry?
0 success
1 other / unclassified, or no slide show running depends
2 anchor / slide / shape / layout / deck not found after re-reading
3 PowerPoint busy / modal dialog yes, back off
4 PowerPoint not running only if user launches it
5 ambiguous match after disambiguating
6 shape has no text frame no — pick a text shape

See Errors & exit codes for the full mapping.


Reading the deck

Side-effect-free reads — they never move the user's view. Start here to learn what's open and addressable before you touch anything.

status

List open presentations, which one is active, and the slide currently in view.

pptlive status
{
  "decks": [
    {"name": "Pitch.pptx", "path": "C:\\Users\\you\\Pitch.pptx", "is_active": true}
  ],
  "viewed_slide": 2
}

Exits 4 (and emits {"decks": [], "viewed_slide": null}) if PowerPoint isn't running.

slides

One row per slide: index, id, layout, title, shape count, has-notes.

pptlive slides
[
  {"index": 1, "id": 256, "layout": "Title Slide", "title": "Acme Q3", "shape_count": 2, "has_notes": false},
  {"index": 2, "id": 257, "layout": "Title and Content", "title": "Agenda", "shape_count": 2, "has_notes": true}
]

outline

Title + body bullets per slide — the Outline-view analog.

pptlive outline
[
  {"slide": 1, "title": "Acme Q3", "bullets": []},
  {"slide": 2, "title": "Agenda", "bullets": ["Intro", "Demo", "Q&A"]}
]

slide read S

Every shape on slide S: anchor_id, name, id, type, placeholder kind, geometry, alt text, and text.

pptlive slide read 2
{
  "index": 2, "id": 257, "layout": "Title and Content", "title": "Agenda",
  "shapes": [
    {"anchor_id": "shape:2:1", "name": "Title 1", "id": 2, "type": "placeholder",
     "placeholder": "title", "geometry": {"left": 38, "top": 27, "width": 884, "height": 104},
     "alt_text": "", "text": "Agenda"},
    {"anchor_id": "shape:2:2", "name": "Content Placeholder 2", "id": 3, "type": "placeholder",
     "placeholder": "body", "geometry": {"left": 38, "top": 145, "width": 884, "height": 385},
     "alt_text": "", "text": "Intro\rDemo\rQ&A"}
  ]
}

shapes --slide S

Just the shape listing for slide S (the shapes array from slide read).

pptlive shapes --slide 2

Reading & writing text

Read any text anchor, search the deck, and write or replace text. Writes are each one Ctrl-Z and preserve the user's view.

Reading text — read

read anchor reads any text anchor; read notes is sugar for notes:S.

pptlive read anchor --anchor-id ph:2:title    # placeholder by semantic kind
pptlive read anchor --anchor-id shape:2:2      # shape by z-order
pptlive read anchor --anchor-id para:2:2:1     # one paragraph
pptlive read anchor --anchor-id cell:4:5:1:1   # one table cell
pptlive read format --anchor-id ph:2:title     # the format probe: {value, baseline, override} per field
pptlive read anchor --anchor-id here:          # the user's current selection
pptlive read notes --slide 2                   # == --anchor-id notes:2
{"anchor_id": "ph:2:title", "kind": "placeholder", "text": "Agenda"}

Exits 2 if the anchor doesn't resolve, 6 if it names a shape with no text frame.

read text-frame-status --anchor-id shape:4:3 reports a shape's autofit state when text looks clipped or overflowing — autosize (the autofit mode), word_wrap, vertical_anchor, the four margins (points), and an overflow_risk flag ("possible" when autosize is off so text can clip, "low" when an autofit mode is active, "unknown" for an unrecognized autosize mode). All reads; the view never moves. To act on what it reports, see shape set-text-frame.

{"anchor_id": "shape:4:3", "autosize": "none", "word_wrap": true, "vertical_anchor": "top",
 "margins": {"left": 7.2, "right": 7.2, "top": 3.6, "bottom": 3.6}, "overflow_risk": "possible"}

find --text "…"

Fuzzy, deck-wide search. PowerPoint has no document-wide character stream, so this traverses every text frame — shapes, table cells, and speaker notes — and reports each hit against a resolvable anchor (shape:S:N, cell:S:N:R:C, notes:S) with a 0-based in-frame offset and a context snippet, in document order. Matching is smart-quote / dash / whitespace tolerant (and case-sensitive), so text an LLM re-typed off a slide still matches the original glyphs. --in scopes the search to a slide:S or any text anchor.

pptlive find --text "Q3 revenue"
pptlive find --text "Demo" --in slide:2          # one slide
pptlive find --text "Metric" --in shape:4:5      # one shape / table / notes anchor
[{"anchor_id": "notes:1", "start": 12, "length": 10, "text": "Q3 revenue",
  "context": "…recap of Q3 revenue versus plan…"}]

Never raises on a miss — zero matches is an empty array and exit 0.

write --anchor-id ID --text "…"

Set the entire text of a text anchor. Preserves the viewed slide; one Ctrl-Z. --text decodes literal \n, \r, \t, and \\ backslash escape sequences into real characters, so you can type them straight in a shell: \n/\r (or \r\n) start a new paragraph — each line becomes its own addressable para:S:N:P\t inserts a tab, and \\ gives a literal backslash. For a soft line break within a paragraph, embed an actual vertical-tab character (\v) — it isn't part of the escape decode.

pptlive write --anchor-id ph:2:title --text "Agenda"
pptlive write --anchor-id ph:2:body  --text "Intro\nDemo\nQ&A"
pptlive write --anchor-id cell:4:5:1:1 --text "Metric"   # a cell is a text anchor
{"ok": true, "anchor_id": "ph:2:title", "kind": "placeholder"}

replace — whole anchor or fuzzy span

Two modes, mutually exclusive:

  • replace --anchor-id ID --text "…" overwrites a text anchor's whole contents — identical in effect to write (including its \n/\r/\t/\\ escape decoding, above).
  • replace --find OLD --text NEW runs the same fuzzy traversal as find and rewrites just the matched span, so the rest of the frame keeps its run formatting. Scope it with --in slide:S (or any anchor). Matches are computed once up front and applied in reverse offset order, so a replacement that re-contains the search text is safe. --text's escape decoding applies here too — a replacement can introduce a new paragraph break via \n/\r.
pptlive replace --anchor-id shape:3:1 --text "New text"     # whole anchor
pptlive replace --find "Acme" --text "Globex" --all         # every occurrence
pptlive replace --find "teh" --text "the" --occurrence 2     # only the 2nd hit

One match auto-applies. Several matches without --all or --occurrence is exit 5 (ambiguous — the matches are listed so you can disambiguate); zero matches is exit 2.


Slides — the slide group

slide covers reads plus the lifecycle verbs. Each mutating verb is wrapped in an edit() fence (one Ctrl-Z) and preserves the viewed slide.

slide layouts

List the deck's layout names — the values add / set-layout accept.

pptlive slide layouts
[{"index": 1, "name": "Title Slide"}, {"index": 2, "name": "Title and Content"}]

slide add

Add a slide; defaults to appending and to the title_and_content layout.

pptlive slide add --layout two_content --index 4
{"ok": true, "index": 4, "id": 261, "layout": "Two Content"}

Reposition the layout's placeholders in the same op with --placeholders (a JSON map of KIND → {left, top, width, height} in points, any subset) — the "body on the left half beside a right panel" case, without an add-then-resize fix-up. The validated geometry is echoed back:

pptlive slide add --layout two_content \
    --placeholders '{"body": {"left": 40, "width": 440}}'

The one-op slide. --title, --body (repeat for one bullet per flag), --content (a JSON map of placeholder KIND[:N] → text or [paragraph items], the set-paragraphs item form) and --notes fill the new slide's placeholders in the same op — one Ctrl-Z, no index lookup, no follow-up writes. Every key is resolved before any text lands, so an unknown-on-this-layout (exit 2) or ambiguous (exit 5body on a two_content slide; use body:1 / body:2) key leaves the freshly added slide untouched. --render PATH also renders the result to a PNG (echoed as image) so the "look" step rides along:

pptlive slide add --layout two_content --title "Q3 results" \
    --content '{"body:1": ["Revenue up 12%", {"text": "Churn flat", "bold": true}], "body:2": "Right column"}' \
    --notes "Lead with the number." --render q3.png
{"ok": true, "index": 4, "id": 261, "layout": "Two Content",
 "content": {"title": "ph:4:title", "body:1": "ph:4:body:1", "body:2": "ph:4:body:2"},
 "shapes": [...], "notes": true, "image": "q3.png"}

Exits 2 (LayoutNotFoundError) on an unknown layout name — the error lists the available ones.

slide delete | duplicate | move | set-layout

pptlive slide delete    --slide 5
pptlive slide duplicate --slide 7              # copy lands at slide 8
pptlive slide move      --slide 9 --to 2
pptlive slide set-layout --slide 4 --layout title_and_content

duplicate and move report the resulting slide's index and id; an out-of-range slide is exit 2.

slide set-transition

Set a slide's entrance transition — effect, animation duration, and auto-advance. At least one of --effect / --duration / --advance-after / --on-click/--no-on-click is required.

pptlive slide set-transition --slide 3 --effect fade --duration 0.75
pptlive slide set-transition --slide 3 --advance-after 5      # auto-advance after 5s
pptlive slide set-transition --slide 3 --no-on-click           # a click no longer advances
{"ok": true, "index": 3,
 "transition": {"effect": "fade", "duration": 0.75, "advance_on_click": true,
                "advance_on_time": false, "advance_time": 0.0}}

--effect is a friendly PpEntryEffect name (fade, cut, dissolve, cover_left, … — see --help for the full choice list) or omit it to leave the effect alone. --duration is the transition animation length in seconds. --advance-after N sets both the auto-advance flag and the timer (pass 0 to keep the timing but still require a click); --on-click/--no-on-click toggles click-to-advance independently. Echoes the resulting transition — the same shape slide read reports.

slide set-background

Give a slide its own solid background color, overriding the deck's master background — or drop the override and revert to inheriting it. Exactly one of --color / --follow-master is required.

pptlive slide set-background --slide 3 --color "#1F1F1F"
pptlive slide set-background --slide 3 --follow-master   # revert to the master's background
{"ok": true, "index": 3, "background": {"follows_master": false, "type": "solid", "color": "#1F1F1F"}}

--color is #RRGGBB. background.follows_master tells you which mode the slide is in; when it's true, type/color reflect the inherited master background (see the master group below for the deck-wide default).

slide export

Render a slide to an image so a vision model can see it. Renders the current (unsaved) state; polite. With no --out, writes a temp file and prints its path — so you can export-then-read in one step. Pass one of --width / --height and the other follows the aspect ratio.

pptlive slide export --slide 2 --out slide2.png --width 1280
pptlive slide export --slide 2                          # temp PNG
{"ok": true, "slide": 2, "path": "C:\\Users\\you\\AppData\\Local\\Temp\\…png",
 "format": "png", "width": 1280, "height": null}

slide geometry N

A spatial map of slide N — the slide size, every shape's bounding box, an off_slide flag, and the overlaps pairs (biggest first) — so you can catch overlapping or off-edge shapes without a render. Axis-aligned only (rotation is reported, not accounted for). A read; the view doesn't move.

pptlive slide geometry 2
{"slide": 2, "size": {"width": 960, "height": 540},
 "shapes": [{"shapeid": "shapeid:2:3", "name": "Title 1",
             "box": {"left": 36, "top": 28, "width": 888, "height": 90},
             "off_slide": false}],
 "overlaps": [], "off_slide": []}

slide animations · slide clear-animations

slide animations N lists slide N's shape animations in play order — each row maps an effect back to its target shapeid, so you can see what animates how without a render (a read). slide clear-animations --slide N removes every animation from a slide and reports how many effects it deleted.

pptlive slide animations 3
pptlive slide clear-animations --slide 3
[{"seq_index": 1, "shapeid": "shapeid:3:4", "shape": "Title 1",
  "effect": "fade", "exit": false, "trigger": "on_click",
  "duration": null, "delay": null}]

Per-slide footer / slide-number / date overrides (the deck-wide defaults live on the master group below — same verb names). slide headers-footers N reads the current settings; the setters mutate (one Ctrl-Z). Setting footer or date text auto-shows that element. A date is either a fixed string (--text) or an auto-updating format (--format, a PpDateTimeFormat int) — not both.

pptlive slide headers-footers 2
pptlive slide set-footer   --slide 2 --text "Acme — Confidential"   # auto-shows
pptlive slide set-footer   --slide 2 --hide                         # just hide it
pptlive slide slide-number --slide 2 --show
pptlive slide set-date     --slide 2 --format 1                     # auto-updating
pptlive slide set-date     --slide 2 --text "June 2026"             # fixed
{"ok": true, "slide": 2,
 "headers_footers": {"footer": {"visible": true, "text": "Acme — Confidential"},
                     "slide_number": {"visible": true},
                     "date": {"visible": false, "text": null, "use_format": null}}}

A footer / date text reads back null while that element is hidden — PowerPoint only exposes the text on a visible element — so a null text next to visible: false means "hidden", not "empty".


Output — render, save & export

Turn the deck into deliverables: a token-cheap snapshot a vision model can see, explicit save / PDF verbs (pptlive never auto-saves), and the media-insertion → MP4 narrated-video path.

snapshot — see the whole deck cheaply

Render slides to PNG so a vision model can see the whole deck at a predictable token cost. --max-dim N caps each slide's long edge in pixels (only ever lowering resolution) — the lever for "render the deck and check my styling landed" without full-resolution bloat. Because every slide shares one geometry, the cap is a uniform per-slide budget; ~1000 stays legible. Renders the current (unsaved) state; polite (doesn't move the view).

Select with --slide N (one) or --slides A-B (an inclusive span); omit both for the whole deck. With --out PATH the PNGs are written (a single slide to that path, multiple as <stem>-s<N><suffix>) and each path is reported; without --out, base64 PNG data is returned inline.

For an exact per-slide pixel size instead of the long-edge cap, pass --width N / --height N (one or both; they override --max-dim, and passing --max-dim together with either is an error). Pixel area — not encoder quality — is what a vision model is billed on, and Slide.Export exposes no JPEG-quality knob, so the dimensions are the only render-cost lever.

pptlive snapshot --max-dim 1000                        # whole deck, base64 inline
pptlive snapshot --out deck.png --max-dim 1000         # -> deck-s1.png, deck-s2.png, …
pptlive snapshot --slides 2-4 --max-dim 800            # just slides 2–4
pptlive snapshot --slide 1 --width 1280 --height 720   # exact pixels (overrides --max-dim)
{"ok": true, "selector": "all slides", "count": 3, "format": "png",
 "max_dim": 1000, "width": null, "height": null,
 "images": [{"slide": 1, "bytes": 24, "base64": "iVBORw0KG…"}, ]}

width/height echo whatever you passed (null when relying on --max-dim).

Save & export — save · save-as · export-pdf

Explicit file output — pptlive never auto-saves. status shows each deck's saved flag (and flags (unsaved) in --text), so you can tell there's unsaved work before deciding to persist it.

  • save — save the deck to its existing file. Exits 1 if the deck has never been saved (no path yet) — use save-as PATH first. (The guard is deliberate: PowerPoint's own Save would silently upload a path-less deck to your default OneDrive/SharePoint folder.)
  • save-as PATH [--format pptx] [--overwrite] — write a .pptx and rebind the working file to it: afterwards the open deck is PATH (its name/path follow), matching PowerPoint's Save-As. Refuses to clobber an existing file unless --overwrite. For PDF, use export-pdf.
  • export-pdf PATH — export a pixel-faithful PDF of the deck's current (unsaved) state. A read: it neither rebinds the working file nor clears its dirty flag, so your .pptx is untouched. Overwrites an existing PDF.
pptlive status                              # see which decks have unsaved changes
pptlive save                                # persist in place (needs an existing path)
pptlive save-as C:\out\v2.pptx              # write + rebind the working file
pptlive save-as C:\out\v2.pptx --overwrite  # allow clobbering
pptlive export-pdf C:\out\deck.pdf          # a read — working file untouched
{"ok": true, "path": "C:\\out\\deck.pdf"}

Media & narrated-video export — media · export-video · video-status

The "build a deck, narrate it, export a video" path.

  • media add --slide N --kind audio|video --path FILE — insert an audio/video clip (embedded; --link keeps the file on disk). Defaults: --autoplay plays on slide entry, --hide-icon hides the speaker icon while idle, and --pace-slide auto-advances the slide to the clip's length so an exported video paces itself to the narration. Turn any off with --no-autoplay / --no-hide-icon / --no-pace-slide. --hide-icon/--no-hide-icon is audio-only — a video has no icon to hide, so passing it with --kind video is a usage error rather than a flag that quietly does nothing. Optional --left/--top/--width/--height (points), --alt-text. Reads carry a media field ({type, length_s, start_s, end_s, muted, volume, autoplay}); type is sound/movie, and start_s/end_s are the trim window in seconds.
  • media set --anchor-id ANCHOR [--muted/--no-muted] [--volume 0.0-1.0] [--start S] [--end S] — set playback options on an existing clip. --start / --end are the trim window in seconds (omit an edge to keep it); --volume is 0.01.0. Values are validated before any COM call, so a bad request is a clean exit 1 rather than PowerPoint's opaque "Illegal value".
  • export-video PATH [--resolution 720] [--fps 30] [--quality 85] [--default-slide-duration 5] [--no-use-timings] — export the deck to an MP4. A read (no rebind). Wraps PowerPoint's async CreateVideo: blocks until done by default (raises after --timeout seconds, default 600). --use-timings (default on) honors per-slide timings + narration; --default-slide-duration is whole seconds (CreateVideo's parameter is an integer, so a fractional value is rejected rather than silently truncated).
  • export-video PATH --no-wait returns the in-flight status immediately; poll video-status until status is done (then the file is ready). A failed encode exits 1.
pptlive media add --slide 1 --kind audio --path intro.mp3   # narrate slide 1
pptlive media add --slide 2 --kind video --path demo.mp4    # a video clip
pptlive export-video C:\out\deck.mp4 --resolution 1080      # blocks until done
pptlive export-video C:\out\deck.mp4 --no-wait              # kick off, then poll
pptlive video-status                                        # {status: "done", ...}
{"ok": true, "path": "C:\\out\\deck.mp4", "status": "done", "status_code": 3}

Shapes — the shape group

Create and place shapes (geometry in points; 1 inch = 72 pt). Each verb is one Ctrl-Z.

shape add

# Text box
pptlive shape add --slide 4 --kind textbox --text "Revenue up 12%" --left 72 --top 72

# Autoshape (see --shape-type choices), with a solid fill and no border
pptlive shape add --slide 4 --kind shape --shape-type star --left 400 --top 120 --width 120 --height 120 \
    --fill "#1E74B5" --line none

# Picture (embedded, never linked) with a drift-proof alt-text handle
pptlive shape add --slide 4 --kind picture --path logo.png --left 600 --top 40 --alt-text "Acme logo"

# Table
pptlive shape add --slide 4 --kind table --rows 3 --cols 2 --left 72 --top 120

# Chart (data is an embedded Excel workbook)
pptlive shape add --slide 4 --kind chart --chart-type column \
    --categories "Q1,Q2,Q3" --series '{"Revenue":[10,20,30],"Profit":[3,6,9]}'
{"ok": true, "anchor_id": "shape:4:3", "name": "TextBox 3", "id": 4,
 "type": "textbox", "geometry": {"left": 72, "top": 72, "width": 180, "height": 29},
 "fill": {"color": null, "visible": true},
 "line": {"color": null, "weight": 1.0, "visible": true},
 "alt_text": "", "text": "Revenue up 12%"}

Notes:

  • --kind picture requires --path; --kind table requires --rows/--cols; --kind chart needs both --categories and --series.
  • --categories takes a JSON array or a comma-separated list. --series takes a JSON object {"name":[values]} or an array of [name,[values]].
  • --fill / --line (textbox/shape only) take a #RRGGBB hex or none (transparent fill / no border); --line-width is the border weight in points.
  • Every shape read carries fill and line ({color, visible[, weight]}); a theme/automatic color reads back as color: null, never a misleading #000000.
  • A new shape lands at the top of the z-order (last slot), so its shape:S:N is the post-add Shapes.Count. A text box created with text auto-fits its height, so a requested --height is advisory when AutoSize is on.

shape move | resize | delete | order

Address by shape:S:N, shapeid:S:ID, or ph:S:KIND (a non-shape anchor like notes:S is exit 2).

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 order  --anchor-id shape:4:3 --to back    # front | back | forward | backward

move and resize echo the new geometry; each needs at least one coordinate/dimension. order restacks the shape and echoes its new 1-based index--to back tucks a freshly added background panel behind the existing content (otherwise it lands on top). Note that delete/restack shift the shape:S:N indices of the shapes they pass; re-read after, or address by shapeid:S:ID (below).

shape fill

Set a shape's fill and/or border (the spatial equivalent of format-text, which is font color). --fill/--line take a #RRGGBB hex or none; --line-width is points. --fill-transparency/--line-transparency are a 0..1 alpha (0 opaque, 1 fully transparent) — the partial-alpha knob, distinct from none (which hides it entirely). Pass at least one. Echoes the shape's updated fill/line (each now carrying transparency).

pptlive shape fill --anchor-id shape:4:3 --fill "#1E74B5" --line none
pptlive shape fill --anchor-id shapeid:4:9 --line "#333333" --line-width 1.5
pptlive shape fill --anchor-id shape:4:3 --fill "#1E74B5" --fill-transparency 0.4

shape gradient-fill / picture-fill / pattern-fill

The non-solid fills (the fill read reports a type of gradient/picture/ patterned and, for a gradient, the stops). Gradients take --colors (one = one-color with --degree brightness, two = two-color, three+ = multi-stop with --positions placing the interior stops) or a named --preset; --style sets the sweep. Pattern fills take a --pattern name plus --fore/--back.

pptlive shape gradient-fill --anchor-id shape:4:3 --colors "#1a73e8,#ffffff" --style vertical
pptlive shape gradient-fill --anchor-id shape:4:3 --colors "#f00,#0f0,#00f" --positions "0,0.4,1"
pptlive shape gradient-fill --anchor-id shape:4:3 --preset ocean
pptlive shape picture-fill  --anchor-id shape:4:3 --path background.png
pptlive shape pattern-fill  --anchor-id shape:4:3 --pattern percent_50 --fore "#1E74B5" --back "#fff"

shape crop / shape crop-to-fit

Crop a picture. crop is the raw primitive — points off each named edge (0 un-crops one), and only the edges you pass change. Note that cropping shrinks the shape box (a 300 pt picture cropped 75 pt becomes 225 pt), so it alone won't hold a layout slot.

crop-to-fit is the verb you usually want: give it a box and it reconciles the aspect mismatch for you.

  • --fit cover (default) — fill the box exactly, centre-cropping the overflow. This is the full-bleed move, and it lands the picture inside the slide instead of oversizing it and letting it hang off the edge — which would permanently poison slide geometry's off_slide flag.
  • --fit containshow the whole picture, shrunk to fit and centred in the box. Nothing is cropped; the box is met on one axis and the other falls short (that shortfall is the letterbox — the slide background shows through).

--left/--top/--width/--height each default to the picture's current geometry, and any existing crop is cleared first, so re-fitting never compounds.

# A 2.08:1 panorama, full-bleed into a 16:9 title panel
pptlive shape crop-to-fit --anchor-id shape:4:3 --left 0 --top 0 --width 960 --height 540

# The whole picture, letterboxed into the same panel instead
pptlive shape crop-to-fit --anchor-id shape:4:3 --width 960 --height 540 --fit contain

# Raw: shave 36 pt off the left, then restore the box yourself
pptlive shape crop   --anchor-id shape:4:3 --left 36
pptlive shape resize --anchor-id shape:4:3 --width 480

A cropped picture reports its crop ({left, right, top, bottom}) in slide read / shapes; an uncropped one omits the key.

shape effect

Set a shape's shadow / glow / soft-edge / reflection (the read reports an effects field with the active ones). --shadow/--glow take a JSON object; --soft-edge/--reflection take an int preset. Pass none to any flag to turn that effect off.

pptlive shape effect --anchor-id shape:4:3 \
  --shadow '{"color":"#333333","blur":8,"offset_x":4,"offset_y":4}' \
  --glow '{"color":"#00AAFF","radius":10}' --soft-edge 4 --reflection 5
pptlive shape effect --anchor-id shape:4:3 --shadow none   # remove the shadow

shape line-style

Set a shape's line dash pattern and/or arrowheads (the line read reports dash plus begin_arrow/end_arrow when set). --dash is a solid/dash/round_dot/dash_dot/long_dash/… name; --begin-arrow/ --end-arrow are none/triangle/open/stealth/diamond/oval, with --begin-arrow-size/--end-arrow-size of small/medium/large. Arrowheads apply to lines/connectors only — a closed shape rejects them (use --dash there). Pass at least one.

pptlive shape line-style --anchor-id shape:4:3 --dash dash_dot
pptlive shape line-style --anchor-id shape:4:5 --end-arrow triangle --end-arrow-size large

shape set-alt

Set a shape's alternative text — a description you can re-find the shape by after z-order drift.

pptlive shape set-alt --anchor-id shape:4:3 --alt-text "Acme logo (top-right)"

Make a whole shape a clickable hyperlink (fires on mouse click), or remove one — the shape-level companion to the run-level link group below (which links a span of text inside an anchor instead). A shape needs no text frame to carry a link. Target is exactly one of --url (external — URL, mailto:, or a file path) or --slide (an in-deck jump to a 1-based slide); --screen-tip is an optional hover tooltip.

pptlive shape set-link --anchor-id shape:4:3 --url "https://example.com/pricing" \
    --screen-tip "Opens the pricing page"
pptlive shape set-link --anchor-id shape:4:3 --slide 12      # jump to another slide
pptlive shape remove-link --anchor-id shape:4:3               # no-op if it has none
{"ok": true, "anchor_id": "shape:4:3", "hyperlink": {"address": "https://example.com/pricing", "sub_address": null}}

remove-link echoes "hyperlink": null.

shape animate · shape clear-animations

Give a shape an entrance (or, with --exit, an exit) animation — the per-shape sibling of slide transitions, appended to the slide's main animation sequence. A shape can carry several effects. --effect is a curated name (appear/fade/fly_in/float_in/wipe/zoom/grow_turn/swivel/wheel/ split); --trigger is on_click / with_previous / after_previous; --duration / --delay are seconds. shape clear-animations removes just that shape's effects (vs. the whole-slide slide clear-animations).

pptlive shape animate --anchor-id shape:3:2 --effect fly_in --trigger after_previous
pptlive shape animate --anchor-id shape:3:2 --effect fade --exit          # animate OUT
pptlive shape clear-animations --anchor-id shape:3:2
{"ok": true, "anchor_id": "shape:3:2", "shapeid": "shapeid:3:7",
 "animation": {"shapeid": "shapeid:3:7", "shape": "Rectangle 4", "effect": "fly_in",
               "exit": false, "trigger": "after_previous", "duration": null, "delay": null}}

shapeid:S:ID — the delete-proof handle

Every shape read emits a stable id (Shape.Id). Address a shape by it with shapeid:S:ID anywhere an --anchor-id is taken. Unlike shape:S:N (a z-order index that shifts when a lower shape is deleted or restacked), the shapeid keeps pointing at the same shape across structural edits — reach for it when a multi-step batch deletes or reorders shapes it later references.

pptlive shape fill --anchor-id shapeid:4:9 --fill "#102030"

shape export

Render a single shape to an image, cropped to its bounds at native pixel size (no size override — Shape.Export doesn't honour one reliably). Polite; temp file when --out is omitted.

pptlive shape export --anchor-id shape:4:3 --out logo.png

shape group / shape ungroup

Combine shapes into one group shape, or free them again. --anchors is comma-separated and takes any shape form (shape:S:N, shapeid:S:ID, or a name); grouping needs at least two, all on the same slide.

Members keep their original ids through both verbs, so a shapeid:S:ID handle taken before a group survives the ungroup — the group itself gets a new id.

pptlive shape group --anchors "shape:4:2,shape:4:3,shape:4:4"
pptlive shape ungroup --anchor-id shapeid:4:57

shape align / shape distribute

Snap a set of shapes to a common edge, or space them evenly.

--how is left/center/right (horizontal) or top/middle/bottom (vertical) for align, and horizontal/vertical for distribute. --relative-to is slide (the default) or selection — align to the slide's edges, or to the bounding box of the shapes themselves. distribute needs three or more shapes to have anything to space out.

# Flush a column of shapes to a common left edge, relative to each other
pptlive shape align --anchors "shape:4:2,shape:4:3,shape:4:4" --how left --relative-to selection

# Space them evenly down the slide
pptlive shape distribute --anchors "shape:4:2,shape:4:3,shape:4:4" --how vertical

shape connect

Draw a connector, in one of two forms:

  • Attached — pass --begin / --end shape anchors and the line is glued to them, so it follows when they move. --begin-site / --end-site (1-based) request a connection point, but they're advisory: PowerPoint reroutes to the shortest path and may re-pick.
  • Geometry — pass --slide plus --left/--top/--width/--height for a free-floating line attached to nothing.

--type is straight (default), elbow, or curved. Shape reads carry a connector field ({type, begin_shape_id, end_shape_id}).

pptlive shape connect --begin shape:4:2 --end shape:4:3 --type elbow
pptlive shape connect --slide 4 --left 100 --top 200 --width 180 --height 0

Hyperlinks on a span of text inside an anchor — the run-level companion to the whole-shape shape set-link (above). Works on any text anchor: a shape, a para:, a table cell:, or notes:.

Address the span either by literal substring (--text, the LLM-friendly form) or by explicit --start (0-based) + --length. An ambiguous --text match raises AmbiguousMatchError (exit 5) rather than silently linking the first hit.

Target is exactly one of --url (external — URL, mailto:, or a file path) or --slide (an in-deck jump to a 1-based slide).

# Link a substring to an external URL, with a hover tooltip
pptlive link set --anchor-id para:4:2:1 --text "our pricing" \
    --url "https://example.com/pricing" --screen-tip "Opens the pricing page"

# Jump to another slide in the deck
pptlive link set --anchor-id shape:4:2 --text "see the appendix" --slide 12

# Explicit offset instead of a substring
pptlive link set --anchor-id notes:4 --start 0 --length 5 --url "https://example.com"

Clears one span's link, or all links in the anchor when no span is given.

pptlive link remove --anchor-id para:4:2:1 --text "our pricing"
pptlive link remove --anchor-id para:4:2:1          # every link in the paragraph
pptlive link list --anchor-id para:4:2:1
[{"text": "our pricing", "start": 12, "length": 11, "address": "https://example.com/pricing", "sub_address": ""}]

start is a 0-based character offset (the same form link set --start takes), and sub_address carries the in-deck target for a --slide jump.


Text structure

paragraphs --anchor-id ID

List a shape's paragraphs, each with its para:S:N:P anchor, text, indent level, bullet, alignment, and effective font (bold/italic/underline as true/false/"mixed", size, font name, color #RRGGBB or null for a theme/automatic color). The font values are rendered — COM exposes no "directly set vs inherited" flag (only color distinguishes a literal from a theme color). Each paragraph also carries space_before / space_after / line_spacing as {value, mode} (where mode is "multiple" or "points" — see the line-spacing note under format-paragraph) and run_sizes, the distinct per-run font sizes — so a stray 5 pt run hiding in an otherwise-18 pt paragraph shows up as "run_sizes": [18.0, 5.0] before it ever renders.

pptlive paragraphs --anchor-id ph:4:body
[
  {"anchor_id": "para:4:2:1", "text": "Revenue up 12%", "indent_level": 1, "bullet": "bulleted",
   "font": {"bold": false, "italic": false, "underline": false, "size": 18.0, "font": "Aptos", "color": "#000000"}},
  {"anchor_id": "para:4:2:2", "text": "Churn down 3%",  "indent_level": 1, "bullet": "bulleted",
   "font": {"bold": false, "italic": false, "underline": false, "size": 18.0, "font": "Aptos", "color": null}}
]

insert --anchor-id ID --text "…" [--before | --after]

Insert a new paragraph relative to any text anchor (para: / ph: / shape: / notes:). Defaults to --after.

pptlive insert --anchor-id para:4:2:3 --text "Cash runway: 30 months" --after

format-paragraph --anchor-id ID [...]

Set alignment, spacing, and indent level on a text anchor. At least one option is required.

pptlive format-paragraph --anchor-id para:4:2:1 \
    --alignment center --space-before 6 --space-after 6 \
    --line-spacing 1.5 --indent-level 2

--alignmentleft / center / right / justify; --indent-level is 1–5 (PowerPoint's only paragraph-indent notion).

Line spacing has two units

--line-spacing is a multiple (1.0 single, 1.5, 2.0). For an exact point height use --line-spacing-points 24. Passing --line-spacing 24 means 24× line height — text shoots off the slide — so a multiple > 5 is rejected (exit 1) unless you add --force. The same split applies to spacing before/after: --space-before / --space-after are points, --space-before-lines / --space-after-lines are multiples. Passing both the point and the multiple form of the same knob is an error.

set-paragraphs --anchor-id ID --json '[...]'

Rewrite an anchor as a clean per-paragraph list — the safe way to author a bullet list. Each item is a plain string or an object with a required text; one item becomes exactly one addressable para:S:N:P (a newline inside an item folds to a soft break), so there's no \n-inference and no separate list apply pass. --file PATH reads the JSON array from a file.

The item keys below are the complete set — font included. Set everything in this one call; a follow-up format-text per paragraph costs 2–3× the COM round-trips for no gain. An unknown key raises a ValueError naming the valid ones (exit 1) rather than being silently dropped.

key value notes
text string required
list_type "bulleted" / "numbered" / "none" "none" strips the bullet
bullet_char single-char string or int code point custom bullet glyph
alignment "left" / "center" / "right" / "justify"
indent_level 1–5 outline depth
space_before / space_after number points
space_before_lines / space_after_lines number multiples (pass one of each pair)
line_spacing number a multiple (1.5); >5 needs force
line_spacing_points number exact points (24)
force bool allow a line_spacing multiple > 5
bold / italic / underline bool
size number font size in points
font string typeface name
color "#RRGGBB" the font color

--autosize none|shape_to_fit_text|text_to_fit_shape sets the frame's autofit mode before the text lands. A content placeholder defaults to shrink-on-overflow autofit, which rewrites explicit size values unevenly as the paragraphs land — and turning autofit off afterwards does not restore them — so pass --autosize none in the same call whenever the items carry a deliberate size hierarchy (the one-call fix for the disable-then-reapply dance).

pptlive set-paragraphs --anchor-id ph:4:body --autosize none --paragraphs \
  '["Overview", {"text": "Revenue up 12%", "list_type": "bulleted", "indent_level": 1,
                  "bold": true, "color": "#2E74B5"},
                 {"text": "Churn down 3%",  "list_type": "bulleted", "indent_level": 1}]'
{"ok": true, "anchor_id": "ph:4:body", "paragraphs": ["para:4:2:1", "para:4:2:2", "para:4:2:3"]}

reset-format --anchor-id ID · shape reset-to-layout --anchor-id ID

Recover a placeholder that's spiralled into a bad state (giant line spacing, 5 pt font, off the slide). PowerPoint has no "clear formatting" button, so the two verbs split the job:

  • reset-format resets paragraph spacing to clean defaults (single line-spacing, zero before/after) — the only unambiguous reset.
  • shape reset-to-layout restores a placeholder's geometry and default font size from its layout's matching placeholder (the "5 pt font / shape off the slide" fix). It only works on a placeholder anchor.
pptlive reset-format --anchor-id ph:4:body
pptlive shape reset-to-layout --anchor-id ph:4:body

The reliable repair sequence is read anchorreset-formatshape reset-to-layoutset-paragraphsslide export (render and check).

shape set-text-frame --anchor-id ID

The setter half of read text-frame-status — the verbs for precise layout, and the reason you no longer need the .com escape hatch to get it. At least one option is required; the resulting frame status is printed.

option effect
--autosize none\|shape_to_fit_text\|text_to_fit_shape Autofit mode. none is the important one — a new text box grows to fit its text, so a --height you pass is advisory until you turn autofit off.
--wrap / --no-wrap Word wrap on/off.
--vertical-anchor top\|middle\|bottom Where text sits vertically in the frame.
--margins N All four inner margins at once, in points.
--margin-left/-right/-top/-bottom N One edge; set after --margins, so --margins 0 --margin-left 12 is a well-defined "flush except the left gutter".

PowerPoint's inner margins default to 0.1 in (7.2 pt left/right, 3.6 pt top/bottom), which silently eats padding math — --margins 0 is the usual first move when you're positioning to the point.

# Pin a box so its geometry is honored exactly, with no internal padding.
pptlive shape add --slide 4 --kind textbox --text "Q3" --left 60 --top 40 \
  --width 300 --height 44 --autosize none --margins 0 --vertical-anchor middle

# Or fix one after the fact.
pptlive shape set-text-frame --anchor-id shape:4:3 --autosize none --margins 0
{"ok": true, "anchor_id": "shape:4:3", "autosize": "none", "word_wrap": true,
 "vertical_anchor": "middle", "margins": {"left": 0.0, "right": 0.0, "top": 0.0, "bottom": 0.0},
 "overflow_risk": "possible"}

shape add --kind textbox|shape takes --autosize, --wrap/--no-wrap, --vertical-anchor, and --margins directly, so a box can be created already pinned; per-edge margins live on set-text-frame. Passing them with another --kind is a usage error (exit 2). An unknown autosize/anchor name or a negative margin is rejected before any COM call, so a bad request never half-applies.

format-text --anchor-id ID [...]

PowerPoint's analog of "apply style" — it has no named paragraph styles, so styling is direct font formatting. At least one option is required.

pptlive format-text --anchor-id ph:4:title --bold --size 40 --color "#2E74B5"
pptlive format-text --anchor-id para:4:2:2 --no-bold --italic --font "Calibri"

Toggles: --bold/--no-bold, --italic/--no-italic, --underline/--no-underline. --color is #RRGGBB.

list apply | remove

Turn a text anchor's paragraphs into a bulleted/numbered list, or strip the list formatting.

pptlive list apply  --anchor-id ph:4:body --type bulleted --char "•"
pptlive list apply  --anchor-id ph:4:body --type numbered
pptlive list remove --anchor-id ph:4:body

Tables — the table group

A table is a shape; address it by slide + z-order (--slide S --shape N). Cells are cell:S:N:R:C anchors you write to with write.

table read

pptlive table read --slide 4 --shape 5
{
  "slide": 4, "shape": 5, "anchor_id": "shape:4:5", "rows": 3, "columns": 2,
  "cells": [
    [{"anchor_id": "cell:4:5:1:1", "text": "Metric"}, {"anchor_id": "cell:4:5:1:2", "text": "Q3"}]
  ]
}

table add-row | delete-row

pptlive table add-row    --slide 4 --shape 5 --values '["Revenue", "$4.2M"]'
pptlive table add-row    --slide 4 --shape 5         # blank row
pptlive table delete-row --slide 4 --shape 5 --row 2

--values is an optional JSON array; the row is filled left-to-right. Both verbs report the new row count.


Charts — the chart group

A chart is a shape; its data lives in an embedded Excel workbook. Address by --slide S --shape N.

pptlive chart read     --slide 4 --shape 6
pptlive chart set-type --slide 4 --shape 6 --chart-type line
pptlive chart set-data --slide 4 --shape 6 \
    --categories "A,B,C" --series '{"S1":[1,2,3],"S2":[4,5,6]}'
{
  "slide": 4, "shape": 6, "anchor_id": "shape:4:6", "chart_type": "column",
  "categories": ["Q1", "Q2", "Q3"],
  "series": [{"name": "Revenue", "values": [10, 20, 30]}]
}

--chart-type is a friendly name (column, line, pie, …); see --help for the full --chart-type choices.

Recolor chart text — chart recolor-text

A chart has no text anchor, so format-text can't reach its internal text. Use chart recolor-text for the coarse "recolor all of it" move — the fix when the inherited (black) axis/legend text is invisible on a dark or custom background.

pptlive chart recolor-text --slide 6 --shape 2 --color "#FFFFFF"
{
  "ok": true, "slide": 6, "shape": 2, "anchor_id": "shape:6:2", "color": "#FFFFFF",
  "recolored": ["chart_area", "legend", "category_axis", "value_axis"],
  "series_data_labels": 0
}

recolored lists the elements actually touched: it only recolors text that's shown (legend/title guarded by their presence; axis tick labels and data labels best-effort, so a pie chart's absent axes are simply skipped). It never adds a legend, title, or labels the deck didn't already display.


SmartArt — the smartart group

A SmartArt diagram is a shape too; its content is a node tree. Address by --slide S --shape N. Create one with shape add --kind smartart (with --smartart-kind and optional --nodes).

pptlive shape add --slide 3 --kind smartart --smartart-kind process \
    --nodes '["Discover", "Design", "Build", "Ship"]'
pptlive smartart read      --slide 3 --shape 2
pptlive smartart set-nodes --slide 3 --shape 2 \
    --nodes '["Plan", {"text": "Execute", "children": ["Build", "Test"]}, "Ship"]'
{
  "slide": 3, "shape": 2, "anchor_id": "shape:3:2",
  "layout": "process", "layout_id": "urn:microsoft.com/office/officeart/2005/8/layout/process1",
  "node_count": 4,
  "nodes": [{"text": "Discover", "level": 1, "children": []}, ...]
}

--nodes is a JSON array of plain strings (leaves) and/or {text, children} objects (children nests recursively). Flat layouts (process, cycle, list, pyramid, venn) take any number of top-level nodes; tree layouts (hierarchy, orgchart) take a single root with nested children — passing more than one top-level node to a tree layout is an error (exit 1).

Recolor node text — smartart recolor-text

Node labels live on each node's text frame, with no per-anchor handle, so smartart recolor-text recolors every node at once — the fix when the inherited (black) node text is invisible on a dark or custom background.

pptlive smartart recolor-text --slide 3 --shape 2 --color "#FFFFFF"
{ "ok": true, "slide": 3, "shape": 2, "anchor_id": "shape:3:2",
  "color": "#FFFFFF", "nodes_recolored": 4 }

Comments — the comment group

Review comments — read a reviewer's notes and reply to them, the "address the comments" workflow. Comments attach to a slide at an (x, y) point and are threaded; you address one for reply/delete by --slide S --index N (1-based, from comment list). comment list with no --slide is a deck-wide roll-up.

pptlive comment list                                  # deck-wide {total, slides:[...]}
pptlive comment list --slide 1                        # one slide's comments + threads
pptlive comment add   --slide 2 --text "Please cite a source."
pptlive comment reply --slide 1 --index 1 --text "Done."
pptlive comment delete --slide 1 --index 1            # removes the comment + its replies
[{"index": 1, "author": "Thomas Villani", "initials": "TV",
  "text": "Tighten this headline.", "datetime": "2026-06-07T10:30:00+00:00",
  "left": 12.0, "top": 12.0,
  "replies": [{"index": 1, "author": "Thomas Villani", "text": "Agreed — will do.", ...}]}]

A new comment binds to the signed-in Office account — the shown author follows that account, not --author/--initials (those reach only the legacy fallback used on a deck that has no existing comment to source an identity from). There is no resolve verb: PowerPoint's COM doesn't expose comment resolution state on current builds. Add/reply/delete are each one Ctrl-Z and don't move your view.


Theme — the theme group

Deck-wide styling: the 12-slot palette and the heading/body typefaces. These are global, anti-polite ops — one change recolors or re-fonts every slide that inherits the theme (the edit is still one Ctrl-Z, and your view doesn't move).

pptlive theme read                                   # {colors:{slot:#RRGGBB}, fonts:{major, minor}}
pptlive theme set-color --slot accent1 --color "#C00000"
pptlive theme set-font  --which major --name "Georgia"        # major = headings, minor = body
pptlive theme set-font  --which minor --name "Calibri" --script latin

--slot is one of the 12 palette slots (dark1/dark2, light1/light2, accent1accent6, hyperlink, followed_hyperlink). --which is major (headings) or minor (body); --script is latin (default), east_asian, or complex_script.


Master — the master group

The primary slide master's text styles (PowerPoint's nearest "named style" analog: title / body / default, 5 outline levels each) and its background. Also global and anti-polite, also one Ctrl-Z.

pptlive master read                                  # {text_styles:{...}, background:{type, color}}
pptlive master format-text-style      --style body  --level 1 --font "Georgia" --size 28 --color "#333333"
pptlive master format-paragraph-style --style title --level 1 --alignment center --space-after 12
pptlive master set-background --color "#1F1F1F"      # solid fill (v0.9 ships solid only)

format-text-style mirrors format-text (--bold/--no-bold, --italic, --underline, --size, --font, --color) and format-paragraph-style mirrors format-paragraph (--alignment, --space-before, --space-after, --line-spacing) — but applied deck-wide to a --style + --level instead of to one anchor. --level defaults to 1 (the natural choice for title); pass --level N (1–5) for the other outline levels. Each needs at least one formatting option.

The master also carries the deck's default headers / footers — the same four verbs as the slide group (headers-footers / set-footer / slide-number / set-date), but setting the deck-wide default every slide inherits unless it has its own per-slide override:

pptlive master headers-footers                       # read the deck defaults
pptlive master set-footer --text "Acme — Confidential"
pptlive master slide-number --show
pptlive master set-date --format 1                    # auto-updating on every slide

Sections — the section group

PowerPoint sections — named spans of slides for organizing a long deck. Structural edits (no view move), each one Ctrl-Z. Sections are addressed by a 1-based --section index.

pptlive section list
pptlive section add    --name "Appendix" --before-slide 9   # start a span at slide 9
pptlive section add    --name "Backup"                      # append an empty trailing section
pptlive section rename --section 2 --name "Results"
pptlive section move   --section 3 --to 1                   # carries its slides
pptlive section delete --section 3                          # keeps the slides…
pptlive section delete --section 3 --delete-slides          # …unless you say so
[{"index": 1, "name": "Intro", "first_slide": 1, "slide_count": 3},
 {"index": 2, "name": "Results", "first_slide": 4, "slide_count": 5}]

Two model notes the spike pinned: starting a section with --before-slide in front of a later slide auto-creates a leading "Default Section" for the slides ahead of it, and delete keeps the section's slides by default (it just drops the boundary) — pass --delete-slides to remove them too. A section with no slides reports first_slide: null.


Selection & navigation

selection

Report the user's current selection, resolved to anchors. A polite read — it doesn't change the selection.

pptlive selection
{"type": "text", "slide": 2, "anchor_id": "para:2:3:2",
 "shapes": [], "paragraph": 2, "text": "Demo"}

type is none / slides / shapes / text. The anchor_id is exactly what --anchor-id here: resolves to.

go-to --anchor-id ID

Move the user's view to an anchor's slide — a deliberate, opt-in view move (unlike the polite edit verbs). Selects the target shape by default.

pptlive go-to --anchor-id shape:3:1
pptlive go-to --anchor-id ph:5:title --no-select

Slide show — the show group

Drive a running slide show like a presenter's clicker. These deliberately change what's on screen. Every control verb prints the resulting state; they need a running show (show next et al. exit 1 if none is running). show state is the read-only verb and never raises.

pptlive show start --from 2        # run from slide 2 (default: the top)
pptlive show next                  # advance a build/slide
pptlive show prev
pptlive show goto --slide 5
pptlive show black                 # blank to black
pptlive show white                 # blank to white
pptlive show resume                # un-blank
pptlive show state                 # read-only
pptlive show end
{"running": true, "state": "running", "current_slide": 5, "slide_count": 12, "position": 5}

An out-of-range --from / --slide is exit 2.


lint / regularize — the consistency audit + one-pass autofix

The last hour before a deck ships is spent on objective, mechanical fixes — every title the same font and size, the title box in the same place on every slide, shapes lined up, nothing hanging off the slide. lint finds them; regularize applies the fixable ones as one Ctrl-Z.

pptlive lint                                   # the default rule set -> {count, findings}
pptlive lint --rule alignment                  # opt-in cluster: edge-alignment / placeholder-off-layout / overlaps
pptlive lint --exclude fonts --within slide:7  # default set minus a tag, findings on one slide
pptlive lint --profile pptlive.lint.json       # a house-style profile (enable rules, tolerances, severities)
pptlive regularize --dry-run                   # plan the fixes without writing
pptlive regularize                             # apply them (targeted + idempotent: a 2nd run is a no-op)
pptlive read format --anchor-id ph:7:title     # the probe a finding is built on

Each finding is {rule, kind, severity, slide, anchor_id, shapeid, message, fixable, fix, adds_content, observed, expected}; fix is literally the exec op(s) regularize runs, and anchor_id is the drift-proof shapeid:S:ID (or para:S:N:P for one bullet). The deck is judged against itself — peers are "the same placeholder on the same layout", and a rule fires only when a clear dominant value exists (≥ 60 % of peers) — so no configuration is needed on any template.

rule default detects fix
title-font-consistent on a title whose font / size / bold / colour differs from the dominant title on that layout format
body-font-consistent on a body bullet whose font / size differs from the dominant value at its indent level format (on the para: anchor)
title-position-consistent on a title box that deviates > 1 pt from the dominant title box — the "jumpy title" shape_move + shape_resize
mixed-runs-in-title on a title whose runs disagree on a font field report-only
shape-off-slide on a shape beyond the slide bounds report-only (a bleeding picture wants shape crop-to-fit)
edge-alignment off (alignment) 2+ shapes whose left / right / top / bottom edges are within 3 pt (profile tolerance) but unequal shape_align
placeholder-off-layout off (alignment) a placeholder moved / resized off its layout position shape_reset_layout (also restores the layout font size)
overlap-unintended off (alignment) two text-bearing shapes whose boxes intersect report-only

regularize emits {applied, skipped, deferred, findings, dry_run, ops_run}skipped are the report-only findings, deferred the content-changing fixes withheld unless --allow-content (none in the current catalogue). A failing fix exits with its error category; the earlier fixes in the pass stay applied (one Ctrl-Z reverts them). Inside an exec script, {"op": "regularize", ...} is a write op that joins the batch's single undo entry.

The profile (--profile PATH, conventionally pptlive.lint.json):

{
  "rules": {
    "edge-alignment": {"enabled": true, "tolerance": 2.0},
    "title-font-consistent": {"enabled": false},
    "shape-off-slide": {"severity": "error"}
  }
}

exec --script ops.json — a batch as one Ctrl-Z

Apply a whole batch script against one connection in one undo entry — the single-process way to build or restyle a slide without a command per change. The script is a JSON object {"label": "...", "ops": [...]}; each op is one of the edit/read verbs, named by its MCP op (write, set_paragraphs, format, shape_add, find_replace, …) and addressed by anchor_id / the same params the MCP ops take. Every op defaults to the edit tool.

Each op's keys are flat (the op's params sit right alongside "op"), exactly the fields the matching MCP op takes — so set_paragraphs takes paragraphs, write takes anchor_id / text, and so on:

{
  "label": "Build Q3 slide",
  "ops": [
    {"op": "slide_add", "layout": "title_and_content", "index": 4, "title": "Q3 Results",
     "body": [{"text": "Revenue up 12%", "list_type": "bulleted"},
              {"text": "Churn down 3%",  "list_type": "bulleted"}]}
  ]
}

(slide_add takes title / body / content / notes, so one op per slide is the norm; the older three-op form — add, then write ph:4:title, then set_paragraphs ph:4:body — still works.)

pptlive exec --script ops.json
  • The batch is one Ctrl-Z — a single automation session, so a partial run (some ops applied before a failure) reverts with one undo.
  • It stops at the first failing op by default — that op's category sets the exit code (2 not-found, 5 ambiguous, …). Pass --continue to run every op and report each outcome.
  • --no-atomic fences each op as its own undo entry instead of one.
  • "Follow the work" view policy. When a batch adds a slide (slide_add / slide_duplicate), the view is left on the last slide it touched rather than snapped back to the pre-batch slide (so building a deck doesn't keep bouncing you to slide 1). Pure-edit batches keep the polite view-restore. Opt out with --no-follow-view (or the PPTLIVE_VIEW_FOLLOW=0 env var); a deliberate navigate op in the batch still wins.

Each result entry carries its index, tool, op, ok, and either the op's result payload or an error token + message:

{"ok": true, "label": "Build Q3 slide", "atomic": true, "count": 3,
 "results": [
   {"index": 0, "tool": "edit", "op": "slide_add",      "ok": true, "result": {"index": 4, "id": 261}},
   {"index": 1, "tool": "edit", "op": "write",          "ok": true, "result": {"ok": true, "anchor_id": "ph:4:title"}},
   {"index": 2, "tool": "edit", "op": "set_paragraphs", "ok": true, "result": {"ok": true, "paragraphs": ["para:4:2:1", "para:4:2:2"]}}
 ]}

A malformed script or unknown op is exit 1 (invalid_args). shape:S:N refs resolve live as each op runs, so address anything you didn't just create by ph:S:KIND / .Name / shapeid:S:ID.


Agent setup — utility commands

Offline bootstrapping for an LLM coding agent — none of these touch PowerPoint.

llm-help [--python]

Print the full bundled agent guide (the SKILL.md content) straight to stdout — one-shot orientation covering the anchor model, every verb, and the exit-code taxonomy. Output is raw Markdown, unaffected by --json/--text, so it reads cleanly straight into a model's context (same idea as --help).

pptlive llm-help              # the CLI guide (default)
pptlive llm-help --python     # the `import pptlive as pl` Python-API guide

install-skill [--cli] [--python] [--system] [--force]

Write pptlive's two agent skills (pptlive-cli and pptlive-python) to disk as SKILL.md files, so an LLM coding tool that reads skills from a folder picks them up. Writes both by default; pass --cli or --python for just one. Lands under ./.agents/skills/<name>/SKILL.md (the current directory) by default, or ~/.agents/skills/<name>/SKILL.md with --system. Refuses to clobber an existing SKILL.md unless --force.

pptlive install-skill                  # both skills, into ./.agents/skills/
pptlive install-skill --python --system --force
{"ok": true, "scope": "local",
 "installed": [{"kind": "cli", "name": "pptlive-cli",
                "path": "C:\\...\\.agents\\skills\\pptlive-cli\\SKILL.md", "bytes": 12345},
               {"kind": "python", "name": "pptlive-python",
                "path": "C:\\...\\.agents\\skills\\pptlive-python\\SKILL.md", "bytes": 9876}]}

install-mcp [--client] [--name] [--directory] [--config] [--print] [--force]

Register the pptlive MCP server in an agent's config, so a client like Claude Desktop or Claude Code can launch it without hand-editing JSON. --client is claude-desktop (default, writes claude_desktop_config.json) or claude-code (writes a project-local .mcp.json); --config PATH overrides the target file for either. --name sets the mcpServers key (default pptlive). By default the registered entry launches the published package via uvx --from "pptlive[mcp]" pptlive-mcp; pass --directory DIR to point at a local checkout instead (uv run --directory DIR pptlive-mcp). --print emits the JSON snippet to stdout instead of writing any file — useful for a client this command doesn't know about. Refuses to overwrite an existing server entry of the same name unless --force. Restart the client to pick up the change.

pptlive install-mcp                                   # Claude Desktop, uvx-published server
pptlive install-mcp --client claude-code --directory C:\repos\pptlive
pptlive install-mcp --print                           # just print the snippet
{"ok": true, "client": "claude-desktop",
 "path": "C:\\Users\\you\\AppData\\Roaming\\Claude\\claude_desktop_config.json",
 "server": "pptlive", "action": "created",
 "entry": {"command": "uvx", "args": ["--from", "pptlive[mcp]", "pptlive-mcp"]}}