Binary files differ
The ACT loop ends in a diff you approve, and on a .pptx that diff is two words. So the agent writes a plan, REX performs it on a copy, and the checker has to prove not only that the change happened but that nothing else did — plus the three numbers I measured wrong on the way.
Every ACT run in REX ends the same way: the agent has changed a working copy, REX shows you the change, and you press Approve or Discard. The whole safety story hangs on that last step — the agent proposes, you read, nothing reaches your file until you agree.
Then I opened a deck. git diff on a .pptx prints:
Binary files differ
Which leaves two options. Either the agent cannot touch PowerPoint and Word at all, or the thing you approve has to be something other than a diff of bytes. The spec’s own warning: without a replacement for that step, “Apply on a deck is less safe than Apply on Markdown, not equally safe.”
This is how the replacement was built, what it refuses to do, and where my measurements were wrong.
What is actually in a deck
Before designing anything I scanned the real decks on my machine — 40 of them, 861 slides, 6,149 shapes — because the design is only affordable if the hard cases are rare.
| Feature | Decks carrying it |
|---|---|
| Speaker notes | 23 / 40 |
| Non-rectangular geometry | 23 / 40 |
| Rotation | 14 / 40 |
| Embedded objects | 7 / 40 |
| Groups | 1 / 40 |
| Charts | 1 / 40 |
| Tables | 0 / 40 |
| SmartArt | 0 / 40 |
OOXML defines about 187 preset geometries. Across all forty decks, eight
appear: rect, roundRect, ellipse, line, chevron, arc, triangle,
rightArrow. The spec’s phrase for this was “the hard cases are hard in theory
and absent in practice”, and it is the sentence that made the whole thing
worth attempting.
The reader survived contact with the corpus too. pptxtojson parsed 29 of 30
decks at 142 ms each and resolved layout inheritance correctly — one deck has
51 of its 52 shapes with no geometry of their own, and the library placed all
52. The one failure was a hard throw on a single file, which REX reports in
place of the document rather than as a blank pane.
Why not a library that round-trips the zip
The obvious write path is a maintained library that opens a .pptx, lets you
change things, and saves it. pptx-automizer is that library. I round-tripped
one 27-slide deck through it with no edits at all:
| Source | After round-trip | |
|---|---|---|
| Parts | 139 | 255 |
| Size | 4,542 KB | 6,414 KB (+41%) |
| Slides | 27 | 54 |
It lost nothing. It rebuilt everything. A reviewer who asked for one sentence changed does not want their deck re-manufactured, and the result is unreviewable for exactly the reason the diff is: everything changed, so nothing stands out.
The alternative, measured the same day: change one <a:t> element in the
slide XML by string surgery and write every other zip entry back untouched.
| Before | After | |
|---|---|---|
| Parts in the package | 132 | 132 |
| Parts lost | — | 0 |
slide4.xml |
14,882 bytes | 14,901 bytes |
So the write half is REX’s own code — about 6,600 lines across pptx/,
docx/ and a shared ooxml/ package, with no LibreOffice and no Python. It
edits only the parts a change names and writes the rest back byte for byte.
The plan
Apply on a deck inverts who does the writing. On Markdown the agent edits the file and REX diffs it. On a deck the agent writes a JSON plan into REX’s cache directory, outside every repository, and REX performs it.
{
"deck": "/abs/path/onion-overview.pptx",
"operations": [
{ "op": "setText",
"slide": 4, "shape": "Text 1",
"from": "Onion: the group data plane",
"to": "Onion: the group control plane" }
]
}
The from field is the whole idea. Every operation that changes something
which already exists must say what it expects to find there, and if the shape
does not currently hold it, the run is refused before a byte is written. It is
the anchor fingerprint from the pointing post applied
to edits: an operation that names its expectation cannot silently act on
something else.
Three more rules, each closing a specific way to land an edit on the wrong thing: shapes are addressed by name, never by index, because the index changes the moment a shape is inserted before it; boxes are fractions of the slide, never points, because a plan in points for a 16 deck misplaces everything on a 4 one; and a plan that both reorders slides and edits them is refused, because “slide 4” means nothing halfway through a reorder.
Then REX performs the surgery on a copy, re-parses the result, and compares it
to what the plan said. Every check has two halves. For setText: the new text
is there, and every shape the plan did not name holds exactly the text it
held before. For deleteShape: the shape is gone, and no other shape on that
slide is.
The second half is the one that matters, and one test exists only to prove it
works: perform two setText operations, declare one, and check that the
validator objects. Its comment says why — “a surgical edit that quietly
altered a slide it was not asked about passes every structural check there
is.”
What you approve is not a diff. It is the plan in words — one line per operation, naming the slide and the shape — and the affected slides rendered before and after, as pictures. The pictures catch what the words cannot: a lengthened sentence running off its card, a font size that is mechanically right and visually wrong.
Here is the full set. PowerPoint got thirteen operations; Word, ported three days later, got ten, because a Word file has no geometry and six of the deck’s operations are about where a box sits.
| Group | PowerPoint (13) | Word (10) |
|---|---|---|
| Text | setText, insertTextBox, setNotes |
setText, insertParagraph, deleteParagraph, moveParagraph |
| Picture | insertImage, replaceImage, insertVideo |
insertImage |
| Shape and style | moveShape, setStyle, deleteShape |
setStyle, setHeadingLevel, setListLevel |
| Structure | reorderSlides, duplicateSlide, deleteSlide, setThemeFont |
insertRow, deleteRow |
Word turned out to be the easier format. Across all 18 Word files on my machine
— 6,140 paragraphs — 97.7% of paragraphs of 25 characters or more are unique by
their text alone, and only 14.1% are split across more than one run. PowerPoint
splits a sentence across runs as a matter of course; Word does it in one
paragraph in seven. The zip module written for decks opened all 18 Word files
and wrote them back with every part byte-identical, without a line changed.
That is when it moved from pptx/ to ooxml/.
The costs I accepted
Every one of these is stated in the preview rather than discovered afterwards, because a cost the reviewer finds later is a cost REX hid.
A rewritten sentence takes the first run’s formatting. Both formats split text across runs whenever formatting changes mid-sentence. REX concatenates the runs to find the match, writes the replacement into the first run, and empties the rest.
// This loses mid-sentence formatting: a bolded word inside a replaced sentence
// comes back unbolded. That is real, it is shown in the preview (§7.7), and it
// is better than guessing how formatting should be redistributed across words
// that no longer exist.
Some paragraphs are locked. A Word paragraph carrying a field, a content control, a footnote reference or a bookmark is refused, never attempted — a field’s visible text is a cached result, and a content control belongs to the template. Across the corpus this locks 16 of 6,151 paragraphs, 0.3%, which is what made refusing affordable.
Tracked changes were withdrawn before a line was written. The plan was to
write <w:ins> and <w:del> so Word would draw REX’s edit as a suggestion. I
killed it on the day it came up: it stops short of making the change. You would
read the two panes, approve, then open Word and approve the same edit again.
PDF stays read-only. A PDF is glyphs at coordinates — no paragraphs, no reflow. On a sample of 60 PDFs, 92% of the fonts on page one were subsets, so the letters a replacement needs may not exist in the file. A feature that works sometimes and fails invisibly the rest of the time is the one kind REX must not ship.
No blank slide. The only way to add a slide is duplicateSlide, then retype
its text. A real insertSlide has to build placeholders from the slide master
— work nobody has done rather than work anybody rejected. It is recorded as the
one genuine gap, so it is not mistaken for a principle.
Where the measurement was wrong
The Word spec re-ran the deck scan over a larger corpus — 72 decks, 1,643 slides — before porting anything. Three numbers did not survive.
Speaker notes. The first scan said 23 of 40 decks had them. The second said
33 of 72. Both counted notes parts. PowerPoint writes an empty notes part for
every slide the moment a deck has a notes master, so both numbers were really
“decks with a notes master”. Read through the text instead, it is 5 decks,
66 of 1,643 slides. The first test fixture for setNotes was a deck whose 27
notes parts hold nothing but the slide number, which is how this was found.
Charts. The first scan found 1 deck in 40. The second found 15 in 72. The answer is unchanged — editing a chart means editing the workbook embedded beside it, and doing half of that produces a chart whose picture and data disagree — but charts moved from “absent in practice” to “the strongest candidate for a later spec”.
Video. insertVideo is the most intricate operation in the set: a media
part, a poster frame, two relationships, a <p:extLst> extension PowerPoint
checks before it will play anything, and a timing node — six things, where a
picture needs four. It was built, tested, and verified to play. The second
scan then measured embedded video in 0 of 72 decks.
The Word spec put it in one line: “a spec that measures badly argues badly.”
There is a fourth, smaller one: the PowerPoint spec’s header still reads
Status: not implemented, while plan.ts lists all thirteen operations. The
document that was the authority on what to build never got told it was built.
The preview is code too
The throwaway renderer that produced the first before-and-after pictures was 70
lines over the parser’s JSON, and it had two bugs worth writing down because
they recur in every renderer that follows. The library emits between
words; a non-breaking space does not wrap, so body text overflowed its card and
was clipped — in a preview whose job is to show whether text overflows its
card. And the page had no <meta charset="utf-8">, so Česká pošta came out
as ÄŒeská poÅ¡ta. The library’s output was correct UTF-8; the page just
never said so. Czech text makes this visible immediately.
The MCP surprise
A deck agent needs judgment about visuals, so the write profile loads a media
plugin with skills for image sourcing and diagram routing. That plugin’s
plugin.json declares five MCP servers: one for generation, one for
ElevenLabs, one that opens an interactive draw.io editor, one that starts a
second headless browser, and one that is a remote HTTP endpoint at
mcp.mermaid.ai — which would send slide content to a third party to draw a
diagram REX already draws locally.
A plugin is loaded whole. Auditing what that meant exposed a gap that had nothing to do with decks:
The
writeprofile has no gate at all.buildHooksreturnsallowfor every tool.
The read profile had always denied un-allowlisted MCP tools. The write profile denied nothing, because the diff-and-wait step was supposed to be the whole protection — the very step that a binary format had just removed. So the write profile gained the same mechanism the read profile had:
const WRITE_MCP_ALLOW = new Set<string>();
Empty by default. With a generation key set, exactly two tool names are added
to it — generate_image and generate_video — and the other four servers stay
refused whether the key is set or not.
What generalises
Two things, and neither is about PowerPoint.
When a diff cannot be reviewed, review the plan. The moment the output stopped being text, the honest unit of review became the intent — a short list of named operations, each stating what it expects to find — plus a rendering of the result. That is a stronger guarantee than a diff, not a weaker one: the original file is not modified at any point before acceptance.
Make the checker say what did not change. A validator that confirms the edit happened is half a validator. The other half re-opens the result and proves every part the plan did not name is byte-identical to what went in — the bare round trip alone came back byte-identical on 18 of 18 Word files. That number, not the operation count, is what lets REX say it did not break anything that was working. It is a promise it can keep, unlike “this deck is valid”, which many real decks never were.