# Building a better PowerPoint API for the AI era

> Source: <https://editide.com/blog/building-a-better-powerpoint-api/>
> Published: 2026-08-26 12:00:58+00:00

## tldr

AI agents create and edit PowerPoint by writing code against libraries with serious limitations. Even basic edits end up slow, expensive, and prone to file corruption.

We built a PowerPoint API that addresses these limitations, making it easier for AI agents and humans to work with PowerPoint programmatically.

On our benchmark of 195 tasks, every model performed better with these tools. And the cheapest model (Luna) beat the best frontier model writing code (Opus), at 96% lower cost, with zero corrupted files.

## Code generation: the status quo and its problems

AI agents generate code to create and edit PowerPoint files. There are two main approaches:

- Automating with the PowerPoint application. The entry points are VBA macros, VSTO (the older generation of Windows add-ins in C#), and Office.js (the modern generation of add-ins – JavaScript web apps running in an embedded browser in the task pane). All of these trigger actions in a running instance of PowerPoint faster than you can click and type.
- Editing the raw Office Open XML (OOXML) inside the
`.pptx`

file. A`.pptx`

is a ZIP of XML files ([see this explainer if you need an intro](/blog/what-is-a-pptx-file/)), so technically any programming language that does text replacement can make changes to the file. The OOXML spec is notoriously difficult, so people use libraries that wrap the XML in a more intuitive object model. The most popular by far is the open-source`python-pptx`

. You write`textbox.text = "Hello world"`

and the library handles the XML.

The second approach doesn't need the PowerPoint application, which is unreliable to run on a server. That makes it the natural fit for AI agents executing code in a sandbox, and given the popularity of `python-pptx`

, agents do the bulk of their work with Python scripts.

The problem is that `python-pptx`

has real gaps. For example, it can't [copy a slide](https://github.com/scanny/python-pptx/issues/132), [add or remove table rows](https://github.com/scanny/python-pptx/issues/86), [create a chart with two value axes](https://github.com/scanny/python-pptx/issues/141), or [replace text without collapsing all its formatting into a single run](https://github.com/scanny/python-pptx/issues/285).

To make up for the gaps, agents fall back to editing the raw XML: unzip, string-replace, and rezip. It's like performing open-heart surgery on the file, and the risks range from silent no-ops to full-blown file corruption. Ask anyone who's worked with OOXML – it's a minefield of tangled inheritance chains, strict element ordering, and confusing rules. And even if you get that right and follow the rules, PowerPoint may disagree about what counts as valid OOXML and flag your file as corrupted even though it passed your validation checks. It runs the other way too: PowerPoint will happily open files that violate the schema, and many of these divergences are undocumented.

Here's an example to illustrate. You create a 100% stacked column chart to show how revenue proportions are evolving.

Naturally, you want to add data labels so your audience actually sees how the exact numbers change. You've worked with pie charts before, so you know that PowerPoint lets you add data labels containing the percentage values – a natural choice for a percentage chart.

It should be a straightforward XML operation. You locate the data labels element for the chart:

```
<c:dLbls>
  <c:showLegendKey val="0"/>
  <c:showVal val="0"/>
  <c:showCatName val="0"/>
  <c:showSerName val="0"/>
  <c:showPercent val="0"/>
  <c:showBubbleSize val="0"/>
</c:dLbls>
```

Flip `<c:showPercent val="1"/>`

. You're extra cautious so you run the new `chart1.xml`

through an OpenXML validator and you get the green light. Easy.

You open the modified file:

Nothing changed. Confused, you pull up the official schema ISO specs, flip to page 4,061, and see clear as day that you did in fact provide valid XML.

It turns out that PowerPoint doesn't render percentage data labels for 100% stacked column charts. You have to manually set the data as percentages, add value data labels, and format the number.

Now let's say you want to try something truly egregious like setting the data label position to "best fit."

You go to the same data labels XML and very carefully insert the data label position element.

```
<c:dLbls>
  <c:dLblPos val="bestFit"/>
  <c:showLegendKey val="0"/>
  <c:showVal val="1"/>
  <c:showCatName val="0"/>
  <c:showSerName val="0"/>
  <c:showPercent val="0"/>
  <c:showBubbleSize val="0"/>
</c:dLbls>
```

You double-check again with page 4,061 of the specs. Good, you inserted before `showLegendKey`

– anything else and you corrupt the file. You run the validator and you get the green light again.

This time when you open PowerPoint:

This is the OOXML minefield. Seemingly benign operations that are schema-correct have catastrophic consequences.

The only reliable defense is to look at what PowerPoint actually renders. A render also resolves information you can't deduce from just looking at the file, like how much space an auto-fit textbox ends up using. But you can't run PowerPoint on a server easily or reliably, so the standard trick is LibreOffice – the open-source Office alternative – which runs headless on a server and renders slide screenshots. It's not perfect fidelity, but it beats flying blind.

So the standard playbook looks like this: run `python-pptx`

scripts, hand-edit raw XML where the library falls short, and screenshot LibreOffice renders to catch what the file content doesn't show.

With today's frontier models like Opus and Sol, you get decent results, but it's slow and expensive. You pay for a huge volume of output tokens and wait through a lot of turns of the edit-render-inspect-repair loop.

It's complicated enough that Anthropic and OpenAI don't trust their affordable models to be useful.

Claude doesn't let you pick Haiku from the model selector in their PowerPoint add-in. ChatGPT doesn't even offer Terra in theirs.

## The tool-based approach

We started building editide in early 2025, when agents were just LLMs, and they produced terrible PowerPoint slides.

The models were smart enough to understand the general direction of a PowerPoint task but were drowning in the execution. You could take a screenshot of a slide, paste it into ChatGPT, ask for suggestions and it would dish out decent advice. "Add data labels to your chart and delete the commentary textbox."

But attach the `.pptx`

and ask it to make the changes itself, and it would take ten minutes to hand back a corrupted file. If you were lucky, you got something that opened, but still took longer to clean up than doing it yourself.

Our idea was to tap into the model's intent and build tools that handled the messy code implementation. Instead of chaining together hundreds of lines of code, the model would use simple JSON tool calls.

The first step was to get LLMs to see what they were working with. Start with the most basic slide there is – the empty title slide PowerPoint gives you when you create a new presentation.

You'd want the LLM to know that there's an empty slide with two placeholders. Something like:

```
{
  "text_boxes": [
    {
      "id": 2,
      "placeholder_text": "Click to add title",
      "position": {"left": 120, "top": 88.38, "z_index": 0},
      "size": {"height": 188, "width": 720},
      "text": "",
      "font_size": 60,
      "font_family": "Calibri"
    },
    {
      "id": 3,
      "placeholder_text": "Click to add subtitle",
      "position": {"left": 120, "top": 283.63, "z_index": 1},
      "size": {"height": 130.37, "width": 720},
      "text": "",
      "font_size": 24,
      "font_family": "Calibri"
    }
  ]
}
```

Here's the actual `slide1.xml`

:

```
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
       xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
       xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
  <p:cSld>
    <p:spTree>
      <!-- nvGrpSpPr + grpSpPr: 12 lines declaring an empty, unnamed group at 0,0 -->
      <p:sp>
        <p:nvSpPr>
          <p:cNvPr id="2" name="Title 1">
            <a:extLst>
              <a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
                <a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
                                id="{AA9B4C4E-0599-F930-19B2-2BAD959FD27A}"/>
              </a:ext>
            </a:extLst>
          </p:cNvPr>
          <p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
          <p:nvPr><p:ph type="ctrTitle"/></p:nvPr>
        </p:nvSpPr>
        <p:spPr/>
        <p:txBody>
          <a:bodyPr/>
          <a:lstStyle/>
          <a:p><a:endParaRPr lang="en-US"/></a:p>
        </p:txBody>
      </p:sp>
      <p:sp>
        <p:nvSpPr>
          <p:cNvPr id="3" name="Subtitle 2">
            <a:extLst>
              <a:ext uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}">
                <a16:creationId xmlns:a16="http://schemas.microsoft.com/office/drawing/2014/main"
                                id="{A86564A9-5F81-901D-4ADC-1B22E0AE0DA8}"/>
              </a:ext>
            </a:extLst>
          </p:cNvPr>
          <p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
          <p:nvPr><p:ph type="subTitle" idx="1"/></p:nvPr>
        </p:nvSpPr>
        <p:spPr/>
        <p:txBody>
          <a:bodyPr/>
          <a:lstStyle/>
          <a:p><a:endParaRPr lang="en-US"/></a:p>
        </p:txBody>
      </p:sp>
    </p:spTree>
    <!-- p:extLst: 5 lines carrying a numeric creation id for the slide -->
  </p:cSld>
  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>
```

Just by looking at the XML, can you figure out what font sizes the title and subtitle use? How about their X or Y coordinates telling you where the text boxes are positioned?

Don't worry, it's not that you're bad at OOXML.

The answers are hiding in three other files: `slideLayout1.xml`

, `slideMaster1.xml`

, and `theme1.xml`

. You have to trace through the inheritance chain to extract the position from the layout, the font size from the master, and the font family from the theme.

It's confusing for LLMs too. But even if they figured out that they had to check four different files to read one slide, it would be insane to do that every time when you can solve it once with code.

So the first tool we built was `read_slide`

: the slide as JSON with the inheritance chains already flattened – actual positions, actual fonts, actual sizes.

Editing is the same picture in reverse. The model changes a property on the JSON it just read, and the engine writes the correct XML.

```
{
  "text_boxes": [
    {
      "id": 2,
      "text": "Hello, world!"
    }
  ]
}
```

We set out to build tools for every property you would want to change – the equivalent of rewriting the entire PowerPoint application with a JSON interface. We did it property by property, poring through 5,000 pages of specs and manually uncovering edge cases where PowerPoint breaks its own rules.

Things like the `bestFit`

landmine from earlier were encoded once, instead of rediscovered by every agent in every session.

## Beyond the basics

Once we were able to edit basic properties reliably, we moved on to more advanced tools that compress an entire workflow into a single call. Tools that do for agents what traditional add-ins like think-cell do for humans: turn a tedious multi-step chore into one action.

Take one of the most common workflows: adding a chart. Formatting one from scratch takes dozens of decisions – colors, axes, labels, etc. In the real world, humans rarely do the full process. They usually copy an existing chart, update the Excel data, and clean up as needed.

So we built a tool that creates a new chart from an existing one as a template. In one call it copies the chart (even across decks), preserves all formatting, clones the embedded Excel workbook, and writes the new data into both the chart cache and the workbook.

Here's that call next to the `python-pptx`

+ XML equivalent. Both are real transcripts of GPT-5.6 Sol running the same task to add a new chart based on a chart in another deck.

The editide tool call:

```
{
  "slide_index": 1,
  "slide": {
    "chart_elements_to_add": [{
      "template_presentation_id": "Column Chart",
      "template_slide_index": 1,
      "template_element_id": 5,
      "position": {"left": 74.18, "top": 186, "z_index": 1},
      "size": {"width": 938.95, "height": 363.04},
      "category_renames": [
        {"index": 0, "new_name": "North"}, {"index": 1, "new_name": "South"},
        {"index": 2, "new_name": "East"}, {"index": 3, "new_name": "West"}
      ],
      "series_list": [
        {"index": 0, "new_name": "Atlas Foods"}, {"index": 1, "new_name": "Beacon Markets"},
        {"index": 2, "new_name": "Crest Retail"}
      ],
      "chart_data": {
        "0:North": {"0:Atlas Foods": 18.2, "1:Beacon Markets": 14.6, "2:Crest Retail": 9.8},
        "1:South": {"0:Atlas Foods": 21.5, "1:Beacon Markets": 16.9, "2:Crest Retail": 11.2},
        "2:East":  {"0:Atlas Foods": 19.8, "1:Beacon Markets": 17.3, "2:Crest Retail": 12.7},
        "3:West":  {"0:Atlas Foods": 24.1, "1:Beacon Markets": 20.4, "2:Crest Retail": 14.9}
      }
    }]
  }
}
```

The code-generation call:

``` python
from pathlib import Path
from zipfile import ZipFile, ZIP_DEFLATED
from lxml import etree
import shutil, tempfile

DST = Path('/work/deck.pptx')
SRC = Path('/work/assets/Column Chart.pptx')
NS = {
    'p':'http://schemas.openxmlformats.org/presentationml/2006/main',
    'a':'http://schemas.openxmlformats.org/drawingml/2006/main',
    'c':'http://schemas.openxmlformats.org/drawingml/2006/chart',
    'r':'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
    'pr':'http://schemas.openxmlformats.org/package/2006/relationships',
    'ct':'http://schemas.openxmlformats.org/package/2006/content-types',
    'x':'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
}
CATS = ['North','South','East','West']
SERIES = [
    ('Atlas Foods',[18.2,21.5,19.8,24.1]),
    ('Beacon Markets',[14.6,16.9,17.3,20.4]),
    ('Crest Retail',[9.8,11.2,12.7,14.9]),
]

with tempfile.TemporaryDirectory() as td:
    td=Path(td); srcd=td/'src'; dstd=td/'dst'
    with ZipFile(SRC) as z: z.extractall(srcd)
    with ZipFile(DST) as z: z.extractall(dstd)

    # Copy the template chart and its embedded workbook intact first.
    (dstd/'ppt/charts/_rels').mkdir(parents=True, exist_ok=True)
    (dstd/'ppt/embeddings').mkdir(parents=True, exist_ok=True)
    shutil.copy2(srcd/'ppt/charts/chart1.xml', dstd/'ppt/charts/chart1.xml')
    shutil.copy2(srcd/'ppt/charts/_rels/chart1.xml.rels', dstd/'ppt/charts/_rels/chart1.xml.rels')
    shutil.copy2(srcd/'ppt/embeddings/Microsoft_Excel_Worksheet.xlsx', dstd/'ppt/embeddings/Microsoft_Excel_Worksheet.xlsx')

    # Add the template chart frame to destination slide 1.
    parser=etree.XMLParser(remove_blank_text=False)
    ss=etree.parse(str(srcd/'ppt/slides/slide1.xml'), parser)
    ds=etree.parse(str(dstd/'ppt/slides/slide1.xml'), parser)
    gf=ss.xpath('//p:graphicFrame', namespaces=NS)[0]
    gf=etree.fromstring(etree.tostring(gf))
    # rId2 is available in the one-shape destination slide.
    gf.xpath('.//c:chart',namespaces=NS)[0].set('{%s}id'%NS['r'],'rId2')
    spTree=ds.xpath('//p:spTree',namespaces=NS)[0]
    spTree.append(gf)
    ds.write(str(dstd/'ppt/slides/slide1.xml'), xml_declaration=True, encoding='UTF-8', standalone=True)

    # Relate slide 1 to the copied chart.
    relp=dstd/'ppt/slides/_rels/slide1.xml.rels'
    rels=etree.parse(str(relp),parser)
    rel=etree.Element('{%s}Relationship'%NS['pr'])
    rel.set('Id','rId2'); rel.set('Type','http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart'); rel.set('Target','../charts/chart1.xml')
    rels.getroot().insert(0,rel)
    rels.write(str(relp),xml_declaration=True,encoding='UTF-8',standalone=True)

    # Register chart and workbook package content types.
    ctp=dstd/'[Content_Types].xml'; ct=etree.parse(str(ctp),parser); root=ct.getroot()
    if not root.xpath('./ct:Default[@Extension="xlsx"]',namespaces=NS):
        e=etree.Element('{%s}Default'%NS['ct']); e.set('Extension','xlsx'); e.set('ContentType','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); root.insert(0,e)
    if not root.xpath('./ct:Override[@PartName="/ppt/charts/chart1.xml"]',namespaces=NS):
        e=etree.Element('{%s}Override'%NS['ct']); e.set('PartName','/ppt/charts/chart1.xml'); e.set('ContentType','application/vnd.openxmlformats-officedocument.drawingml.chart+xml'); root.append(e)
    ct.write(str(ctp),xml_declaration=True,encoding='UTF-8',standalone=True)

    # Replace chart caches only, retaining every formatting and layout element.
    cp=dstd/'ppt/charts/chart1.xml'; chart=etree.parse(str(cp),parser)
    ser_nodes=chart.xpath('//c:barChart/c:ser',namespaces=NS)
    assert len(ser_nodes)==3
    for ser,(name,vals) in zip(ser_nodes,SERIES):
        txv=ser.xpath('./c:tx/c:strRef/c:strCache/c:pt/c:v',namespaces=NS)[0]; txv.text=name
        catpts=ser.xpath('./c:cat/c:strRef/c:strCache/c:pt',namespaces=NS)
        assert len(catpts)==4
        for pt,cat in zip(catpts,CATS): pt.xpath('./c:v',namespaces=NS)[0].text=cat
        valpts=ser.xpath('./c:val/c:numRef/c:numCache/c:pt',namespaces=NS)
        assert len(valpts)==4
        for pt,val in zip(valpts,vals): pt.xpath('./c:v',namespaces=NS)[0].text=str(val)
    chart.write(str(cp),xml_declaration=True,encoding='UTF-8',standalone=True)

    # Update the embedded workbook cells and table headers.
    wbfile=dstd/'ppt/embeddings/Microsoft_Excel_Worksheet.xlsx'
    wbdir=td/'wb'
    with ZipFile(wbfile) as z: z.extractall(wbdir)
    shp=wbdir/'xl/worksheets/sheet1.xml'; sh=etree.parse(str(shp),parser)
    def cell(ref):
        return sh.xpath('//x:c[@r=$ref]',namespaces=NS,ref=ref)[0]
    for col,(name,vals) in zip(['B','C','D'],SERIES):
        c=cell(col+'1'); c.xpath('./x:is/x:t',namespaces=NS)[0].text=name
        for row,val in enumerate(vals,2): cell(f'{col}{row}').xpath('./x:v',namespaces=NS)[0].text=str(val)
    for row,cat in enumerate(CATS,2): cell(f'A{row}').xpath('./x:is/x:t',namespaces=NS)[0].text=cat
    sh.write(str(shp),xml_declaration=True,encoding='UTF-8',standalone=True)
    tp=wbdir/'xl/tables/table1.xml'; table=etree.parse(str(tp),parser)
    cols=table.xpath('//x:tableColumn',namespaces=NS)
    for col,(name,_) in zip(cols[1:],SERIES): col.set('name',name)
    table.write(str(tp),xml_declaration=True,encoding='UTF-8',standalone=True)
    with ZipFile(wbfile,'w',ZIP_DEFLATED) as z:
        for f in sorted(wbdir.rglob('*')):
            if f.is_file(): z.write(f,f.relative_to(wbdir).as_posix())

    # Repackage destination.
    out=td/'out.pptx'
    with ZipFile(out,'w',ZIP_DEFLATED) as z:
        for f in sorted(dstd.rglob('*')):
            if f.is_file(): z.write(f,f.relative_to(dstd).as_posix())
    shutil.copyfile(out,DST)
print('updated',DST)
```

The code run also spent nine bash calls unzipping both decks and reading slide XML and the embedded workbook before it could write the script. For the entire task end-to-end, GPT-5.6 Sol with editide did it in 25 seconds with 903 output tokens vs. 4,245 output tokens in 62 seconds.

This is one of the big token economics levers. The agent states its intent – this chart, this data, here – and the engine does everything the script would have done (assuming it's correct).

The difference is easier to see side-by-side – see [our add-in and Claude for PowerPoint on a similar task](/blog/editide-vs-claude-powerpoint-add-in/).

Here's another real-world slide: a text-heavy table where each row corresponds to a company.

Say you want to make this visually more compelling and use logos instead of text for the company names. You find the image files online, and now you just have to size and position them in the middle of the corresponding cells.

To vertically center each logo, you need to calculate the slide's Y coordinate at the midpoint of the given row. The math is straightforward: table's Y coordinate + (sum of previous row heights) + row height / 2.

But when you inspect the XML, the row heights are all set to `0`

.

This is not a bug – it's code for PowerPoint to render the row height to the minimum required to fit the text content. This means that the only way to calculate the precise placement of the logos is to replicate PowerPoint's text rendering engine.

The answer depends on font metrics, wrapping, margin, line spacing. It's not something you vibe code with Fable over the weekend.

This is where code-generating agents reach for LibreOffice screenshots. They try a position, take a screenshot, squint, nudge, and try again. And even that relies on LibreOffice achieving perfect fidelity.

We actually built the text-measuring engine, so the arithmetic is trivial and the agent places every logo right on the first try.

That's the trade in a nutshell: we pay the engineering cost once for a deterministic solution, so the agent never pays it probabilistically for every task.

## Results

To measure the difference, we published [Pls Fix Bench](/blog/pls-fix-bench/): 195 PowerPoint editing tasks inspired by real investment banking nits, named after the "pls fix" emails senior investment bankers send when a tiny thing on a slide is wrong. These are targeted edits, not open-ended deck design – "move the chart's legend to the right," "color every negative figure in the table red" – the kind a junior banker is expected to nail 95–100% of the time.

We ran 11 models through every task twice: once as a coding agent with `python-pptx`

, raw XML surgery, and a screenshot tool, and once as an agent equipped with our tools.

Three findings:

**The cheapest model with editide beats the best model writing code.** GPT-5.6 Luna passed 94.4% of tasks – more than any code-writing model, including Claude Opus 4.8 at 91.8% – at 96% lower cost and 62% lower latency. Claude Haiku 4.5 with editide also beat code-only Opus, at 83% lower cost.**Every model improved.** All 11 passed more tasks with the tools, with latency down 31–83% and output tokens down 73–91%.**Zero integrity violations.** 93 of 2,145 code-only runs damaged the file – corrupt XML, or chart caches out of sync with the embedded workbook. 0 of 2,145 editide runs did.

## The Bitter Lesson objection

The obvious objection: tools are a crutch, the next model generation will just write better code, wait six months. The accuracy gap will keep narrowing, sure. But the case for a new PowerPoint API no longer rests on "models are too dumb," and these points are true in a world with smarter models:

**It's cheaper and faster.** Even for a model that passes every task, a JSON property is fewer tokens than a script, and a one-shot edit is fewer turns than edit-render-inspect-repair. Whatever the frontier brute-forces with code, a smaller model does sooner, faster, and cheaper with tools – and there will always be a smaller model.**The file doesn't have all the answers.** Text measurement lives in PowerPoint's rendering engine, not in the XML. PowerPoint's divergences from its own schema aren't in the spec – they were found by experiment, against PowerPoint itself. Models will absorb the well-documented landmines over time, but an engine will be more reliable.**The API doesn't need an AI agent at all.** The engine is deterministic software behind an API, so it upgrades ordinary programmatic workflows too. A report-export pipeline can call it directly.

Take away the OOXML wrangling, and you can sort PowerPoint tasks by their actual difficulty. The tedious bulk – updating numbers, tweaking layout, adjusting font sizes – gets assigned to cheap, fast models, and you save the expensive one for work that needs real judgment. On our benchmark this already works: the cheapest model we tested beat the best one writing code, at 96% lower cost.

If you're building a product that needs to ship editable PowerPoint, or an agent that needs to edit it, the [API and MCP server](/docs/) are open – see for yourself.
