hands-on-deck and the checkpoint that decides whether an agent gets near your decks

Back to blog
Mehran Mozaffari·· Updated 26 August 2026

What deck.py actually does under the hood

hands-on-deck, now at version 3.1.0, comes down to a single CLI, deck.py, organized around four commands. inspect --slide N --brief gives you one line per shape — id, type, geometry, a text preview — enough to orient an agent without spending tokens rendering the full JSON tree of a slide. apply patch.json -o out.pptx --fix --render img/ is the actual write path: hand it a batch of declarative ops and it produces a new file, optionally repairing what it can and rendering the result to an image in the same call. diff out.pptx deck.pptx produces a structural changelog — what moved, what text changed — instead of asking anyone to eyeball two XML trees side by side. docs prints the full CLI reference on request, so the reference material doesn't need to live in context up front.

The patch vocabulary itself is around 30 ops — set-text, replace-text, swap-image, duplicate, set-theme, replace-color, merge, and the rest — each a small, named, closed-form mutation rather than an arbitrary instruction.

It ships as an Anthropic Agent Skill, which means it drops straight into Claude Code or claude.ai. That packaging is close to incidental, though — underneath it's a Python CLI with no server, no auth, no API key. Any agent that can shell out can drive it.

The decision to represent every edit as a declarative JSON op, instead of hand-writing OOXML or regenerating the deck from scratch, is the choice everything else here is built on. Hand-edited OOXML gives an agent essentially unbounded room to produce something syntactically legal and semantically wrong. Full regeneration throws away whatever's encoded only in the original file — brand fonts, logo placement, image treatments — none of which round-trips through a prompt. A closed set of named ops is the only one of the three small enough to check exhaustively before anything touches disk, which is exactly the property the next layer of this design depends on.

The atomic patch: how validation buys safety before a byte is written

The core guarantee is fail-closed: the entire patch batch is validated before a single byte is written. If one op in a ten-op batch is invalid, none of the ten apply. That sounds like a small ergonomic choice, but it's the difference between a tool you can trust unattended and one you have to babysit. A naive apply-as-you-go editor — mutate on each call, no all-or-nothing pass — can leave you with a file that's partially edited in a way nobody asked for: op three succeeded, op seven failed, and now the deck is in a state that exists nowhere in anyone's intent. That's not hypothetical; it's the default behavior of tool-call-based editors that treat each call as its own transaction instead of validating the batch as a unit.

The other half of the design is what happens on rejection. The error doesn't just say which op failed — it embeds the target slide's live shape inventory: ids, types, geometry, current text. That means an agent that gets a rejection has everything it needs to retry correctly in the same turn, no second round trip to re-inspect the slide it just tried to edit. It's a small detail that removes an entire class of wasted inspect-apply-inspect loops.

A few ops get default behavior that matters in practice: set-text and duplicate inherit the source shape's formatting automatically, so you're not re-specifying font, size, and color on every text edit, and swap-image preserves aspect ratio and framing rather than stretching a replacement image to fill whatever box happened to be there.

flowchart TD
    A[inspect --slide N --brief] --> B[patch.json batch: set-text, swap-image, duplicate, replace-color]
    B --> C[apply patch.json]
    C --> D{Pre-validate every op in the batch}
    D -->|Any op invalid| E[Reject: return live shape inventory - ids, types, geometry, text]
    E --> F[Nothing written to disk]
    D -->|All ops valid| G[Atomic write to out.pptx]
    G --> H[Optional --render to img/]
    H --> I[diff out.pptx deck.pptx]

From flagged to fixed to residue: the linter's three-way split

Once a patch has actually written a file, a separate linter runs and checks four concrete things: text overflow, reported in exact inch measurements rather than a pass/fail bit; shapes that fall off the edge of the slide; text sitting on top of other text, or on top of an image, in a way that makes either unreadable; and near-miss alignment, measured against a grid the linter infers from the deck itself rather than a fixed template — something like being 0.14 inches short of a cluster of shapes that all line up at 10.66 inches.

--fix then does deterministic repair on a subset of what it found: it grows text boxes, shrinks fonts within floors it won't go below for readability, and nudges off-slide shapes back onto the slide. It re-measures after each repair, so a fixed shape actually gets re-checked, not just marked done. What's left after that pass is reported as residue rather than silently dropped or forced closed — an off-slide image, for instance, doesn't get auto-moved, because bleed off the edge of a slide is a legitimate design choice as often as it's a mistake, and the tool isn't in a position to know which. Residue comes back with suggested ops attached, so the decision to apply them is explicit rather than assumed.

That three-way split — clean, fixed, residue — is the actual mechanism here, not a vague notion of "checking quality." Clean means the linter found nothing. Fixed means it found something it's confident enough to repair on its own. Residue means it found something and is explicitly declining to decide for you. That last category is where the tool's own instructions say to stop and look at the render before calling anything done — a discipline that rhymes with what I've written about the orchestration patterns that hold up a human checkpoint as a hard stop rather than a suggestion: the checkpoint only does anything if the categories arriving at it are honest about what's still undecided.

stateDiagram-v2
    [*] --> Unverified
    Unverified --> Linted: apply runs post-apply linter
    Linted --> Clean: no overflow, off-slide, overlap, or near-miss alignment found
    Linted --> Fixed: --fix applies deterministic repair (grow boxes, shrink fonts within floors, nudge shapes on-slide), then re-measures
    Fixed --> Residue: unresolved issues reported with suggested ops (e.g. off-slide image left untouched, bleed may be intentional)
    Clean --> HumanReviewed: render is actually looked at
    Residue --> HumanReviewed: render is actually looked at
    HumanReviewed --> [*]

Where the human checkpoint actually sits

SKILL.md doesn't hedge on this: look at the rendered image before declaring success, every time, not as a nice-to-have but as an instruction the tool's own documentation treats as load-bearing. It goes further than that in one specific way — it warns against waving off an overflow flag on display-size text without zooming into that exact shape, because the estimator that measures text in a browser context and the layout engine that actually renders a slide in PowerPoint disagree, and they disagree worst on serif fonts. That's a narrow, specific warning, not a generic "review your output" disclaimer, and it tells you the tool's authors already know where their own measurement diverges from ground truth.

Three concrete mechanisms carry that judgment call to a human rather than resolving it in code. First, alignment deviations in the 0.03–0.15 inch range are reported but never auto-corrected — the tool's stated reasoning is that intentional asymmetry is real, and closing that gap automatically would sometimes be "fixing" a deliberate design choice. Second, the residue report itself is an explicit handoff: whatever --fix can't resolve comes back with suggested ops attached, rather than getting forced closed or quietly dropped. The decision to apply those ops is left as a decision, not an assumption. Third, brand and firm-specific rules are meant to live as a layer on top of the engine, not as a fork of it — which keeps the base tool's verification logic stable while letting an org bolt on its own harder constraints without touching the write path underneath.

None of that works if review happens once, at the end, over a finished deck — that's the failure mode of a single-pass regeneration tool, where a human either accepts or rejects the whole output in one look. hands-on-deck's loop instead produces a render at each apply call, which means review is distributed across the sequence of edits rather than clustered at export. That's also what makes fanning review out to one sub-agent per slide tractable: each sub-agent only needs that slide's render and its inspect --brief output, not the whole deck, and it can hand back a verdict tied to specific shape ids instead of passing an image up the chain for someone else to also look at.

sequenceDiagram
    participant O as Orchestrating agent
    participant S1 as Sub-agent (slide 1)
    participant S2 as Sub-agent (slide 2)
    participant SN as Sub-agent (slide N)
    participant H as Human reviewer

    O->>S1: slide 1 render + inspect --brief
    O->>S2: slide 2 render + inspect --brief
    O->>SN: slide N render + inspect --brief
    S1-->>O: pass/fail verdict (shape ids)
    S2-->>O: pass/fail verdict (shape ids)
    SN-->>O: pass/fail verdict (shape ids)
    O->>O: aggregate verdicts
    alt any fail, or residue outstanding
        O->>H: hand off before export
    else all pass
        O->>O: proceed to export
    end

When the linter lies: the silent gaps behind 'zero residue'

The render-and-eyeball rule from the previous section assumes something specific: that the overflow estimator errs conservative, flagging things that turn out fine more often than it clears things that are actually broken. Two open, unmerged bugs against main show the opposite is possible — a clean report that isn't proof of anything.

The sharper of the two is a straightforward unit-conversion defect in _estimate_frame_overflow(). Every other measurement in that function is already converted to 96-DPI pixels, but the font size handed to ImageFont.truetype() is passed in points, while that call expects pixels. The result is text measured at roughly 75% of its true rendered width — lines that will genuinely wrap and overflow get scored as fitting on one line. It's not an edge case that shows up on unusual fonts or platforms; it's a fixed error baked into the estimator, so it misfires the same way on every deck, every font, every machine. The reporter's repro is concrete: a textbox overflowing by 0.204 inches reports nothing before the one-line fix, and 0.14 inches of overflow after it.

The second is environment-dependent in a way that's arguably worse for a team relying on consistent output: when a deck's declared font isn't installed on the machine running deck.py, the fallback font picker can substitute the wrong font class entirely — a missing monospace font falling back to a proportional one — which under-measures width and misses a real second-line overflow colliding with a neighboring shape. This one actually shipped: a 2026-06-15 eval run produced a final.pptx with the defect intact, apply --fix reported zero fixed and zero residue, and three separate human and agent judges caught the overflow on sight anyway. A related, unfiled quirk on macOS compounds this — Arial can resolve by prefix match to Arial Hebrew, whose metrics run about 55% wider than the font actually being requested, silently distorting every measurement on the platform most desktop users of this tool are likely running.

The takeaway isn't that the linter is worthless — it's that a clean report describes what the estimator managed to check, not what's true of the rendered slide. That's precisely why the earlier rule holds even for edits that feel too boring to warrant a second look: the tool that would tell you to skip the render is the same one that can't always be trusted to know when it missed something.

Bug Symptom Root cause What you need to compensate manually
#18 Overflowing text reported as fitting on one line font_size passed in points to an API expecting pixels, undermeasuring width by ~25% Don't trust "0 fixed, 0 residue" on text-heavy slides — render and check line wrap directly
#3 Second-line overflow missed when a declared font is uninstalled Fallback font picker substitutes the wrong font class (e.g. monospace → proportional) Match the font set on every machine running deck.py to the deck's actual fonts, dev and CI alike
#7 Concurrent render calls deadlock or fail All soffice invocations share one default LibreOffice profile with no per-call isolation Serialize render calls in any fan-out review pipeline, or patch in a per-invocation profile before parallelizing
#15 Duplicated slide loses its custom background slides duplication copies spTree shape content but not the sibling p:bg element Re-apply set-slide ... background immediately after any slide duplication that isn't using the default background

What atomic writes don't cover: slides, logos, and the XML escape hatch

The fail-closed guarantee — validate the whole batch, write nothing on any single invalid op — is specific to apply. It doesn't extend to the rest of the CLI surface, and two open issues show what falls through that gap.

slides, used for structural reordering and duplication, doesn't go through the patch-validation path at all. Duplicating a slide copies its spTree shape content but not the p:bg element that sits alongside it under p:cSld — a sibling, not a child, so the copy step misses it. The duplicate silently falls back to the layout or master background instead of the slide it was copied from. Nothing catches this: no validation error, no lint flag, because it's a rearrangement bug that never touches the patch/lint system in the first place.

The second gap is structural in a different sense — it's invisible to the rule the whole design otherwise leans on. A full-bleed picture that already has a logo or footer baked into its own pixels, placed on a layout or master that also draws a logo or footer in that same zone, produces a slide with two logos stacked. The composited render is the actual final output, so looking at it isn't a mistake — there's nothing wrong with what's on screen from the reviewer's vantage point. The defect only exists in the relationship between the picture and the layer underneath it, which a render never separates out. It recurred enough in real use, flagged independently by two people in the same week, that the team's answer wasn't "review harder" — it was a dedicated non-visual, deterministic check, check_logos.py, built specifically because visual review structurally can't catch this class of problem.

Beyond both of those, the tool names its own boundaries plainly: native charts, shape animations, embedded video or OLE objects, and merged table cells are out of scope by design, not oversights. The documented way around that boundary is direct xml get/xml set access — parse-checked and lint-checked, but not covered by the same validate-before-write guarantee that makes apply safe to run unattended. Anyone adopting this for decks that lean on charts or merged cells — which is most consulting and finance decks — will hit that escape hatch routinely, and it asks for a different skill than writing patch JSON.

hands-on-deck against the field: patch editors, MCP servers, and full regeneration

Anthropic's own pptx skill is the tool hands-on-deck benchmarks itself against in its own evals, and the two diverge at nearly every layer despite solving the same job. Editing an existing deck with Anthropic's skill means unzip, hand-edit the slide XML directly, zip it back up — with an explicit rule to do all structural work before content work, because there's no atomicity and a bad ordering can leave the archive in a broken intermediate state. Validation exists (validate.py, then markitdown/pdftoppm for visual QA), but it's a separate script the agent has to remember to invoke after the edit, not a gate the write path itself enforces. hands-on-deck's atomic pre-write validation — reject the whole batch, write nothing, hand back the live shape inventory — has no equivalent there.

The OSS MCP servers, GongRzhe's Office-PowerPoint-MCP-Server chief among them, are the MCP-native version of roughly the same idea: python-pptx underneath, 32 tools across 11 modules. But each tool call mutates the file immediately — there's no batched, all-or-nothing pass across a set of edits, so a multi-op sequence can partially apply and leave output nobody asked for. Validation is limited to text and overflow checks with no rendering step at all, which means it structurally cannot catch off-slide shapes, image-over-text overlap, or alignment drift, because it never looks at what the slide actually looks like.

COM-automation servers like ppt-mcp solve the fidelity problem a different way: they drive a live, licensed PowerPoint instance over Windows COM, so the rendering is PowerPoint's own layout engine, not a re-implementation of it. That buys perfect visual accuracy at the cost of Windows plus an installed PowerPoint license, with no parallelism and no containerization — you can't run ten of these in CI the way you can run ten headless CLI invocations.

Full-generation SaaS tools like Gamma are a different job entirely — describe a topic, get a finished deck for a human inside a web app, not sixty precise edits to an existing branded file. hands-on-deck's own framing of regeneration as lossy is self-serving, but it's also technically correct: brand fonts, logo placement, and image treatment live only in the original file, and no prompt reconstructs them.

The real axis of competition, then, isn't feature count — it's where verification lives. hands-on-deck buys atomic validation and render-based lint inside the write path at the cost of a closed ~30-op vocabulary. Everyone else keeps full OOXML expressiveness and leaves verification as a step the agent has to remember to run.

Tool Validation model Rendering/lint step Deployment surface Expressiveness ceiling
hands-on-deck Atomic pre-write (whole batch fails closed) Built into apply, geometry-aware, flag/fix/residue CLI, no server, no auth Closed ~30-op vocabulary; XML escape hatch for the rest
Anthropic pptx skill Separate script (validate.py), agent must invoke it Bolted on after edit (pdftoppm/markitdown) CLI Arbitrary OOXML / pptxgenjs — unbounded
OSS python-pptx MCP servers None (incremental, per-call, no batch) Text/overflow only, no rendering Persistent MCP server Bounded by python-pptx's API, but no closed op set
COM-automation MCP servers Native (real PowerPoint state) Native (real PowerPoint rendering) Windows + licensed PowerPoint install Full PowerPoint feature set, no parallelism

Provisioning and governing this for production, not just a laptop

The "just a CLI" pitch undersells what actually has to be installed for the safety story to hold. The base is Python 3.9+ with python-pptx and Pillow for the CLI and the overflow estimator — that part is a normal pip install. Rendering and verification need system binaries: LibreOffice (soffice) to convert to PDF and Poppler (pdftoppm) to rasterize it, neither of which is pip-installable, and neither of which is guaranteed to exist — or be permitted — in a locked-down agent sandbox or a hosted CI runner. The HTML-authoring path, html2patch.py, adds a third stack on top: Playwright driving headless Chromium, a real browser process with its own security surface, not just XML manipulation. That's a meaningfully heavier footprint than "drops in as a CLI" implies once you're provisioning it outside a developer's own machine.

Issue #7 is where this stops being theoretical. deck.py render shells out to soffice --headless --convert-to pdf without setting -env:UserInstallation=, so every render call shares LibreOffice's one default profile. Two soffice processes started at the same time contend on that profile's lock, and the second one deadlocks or fails outright. That collides directly with the fan-out review pattern described earlier in this piece — one sub-agent per slide, each rendering independently to stay within a bounded context. Run that pattern as written and you either serialize every render call yourself, which erases the latency benefit fan-out was supposed to buy, or you apply the open PR's fix — a temp profile per invocation via -env:UserInstallation, at roughly a one-second startup cost each — before you scale past one or two concurrent renders.

Governance compounds the provisioning problem. Version bumps are enforced by convention in CLAUDE.md, not by CI — a maintainer has to remember to bump plugin.json and marketplace.json together. Current is 3.1.0, and four known, reproducible bugs (#3, #7, #15, #18) sit open on main right alongside active feature PRs, with no indication any of them is prioritized ahead of new op work. Given that cadence, tracking HEAD in a production pipeline is a bet you're implicitly re-taking on every pull. Pin to a specific tag or commit you've validated against your own font set, OS, and LibreOffice version, and re-run that validation on every bump rather than assuming the next patch release is strictly additive.

Where this fits into a reader's own agent stack

The most direct build sitting on top of this is an export gate for anything client- or exec-facing: wrap apply --fix --render so the residue report isn't just logged, it's a required approval step — nothing leaves the pipeline while residue is outstanding, and the render goes to a human via Slack or email alongside it. That gate should carry one more check the residue mechanism can't provide on its own: a small deterministic script, modeled directly on check_logos.py, that runs after any swap-image or picture-add op lands on a branded layout and checks the picture against the layout's own logo and footer zones. That defect class produces a render that looks completely clean, because the composite is genuinely correct-looking — the problem lives in a layer the render never separates out, so no amount of staring at the image catches it. Worth pairing that gate with real skepticism toward "zero residue" on text-heavy slides specifically, since the overflow estimator has shipped false negatives before pinning fonts and validating its output against your own rendered slides.

The fan-out review pattern from SKILL.md is a second build, closer to a QA harness than a gate: one sub-agent per slide, each given only that slide's render and its inspect --brief output, returning a pass/fail verdict tagged to specific shape ids rather than passing images up a chain for someone else to re-look at. The part worth respecting rather than skipping is the concurrency ceiling — render calls share one LibreOffice profile by default, so scaling this past a couple of slides at once means either serializing renders or wiring in a per-call temp profile first.

The least obvious but most durable build is borrowing hands-on-deck's own eval design for validating whatever brand or compliance rules a team bolts on top of the base engine. Run the same brief through the plain toolchain and through the variant with the custom rule layer applied, anonymize both outputs before anyone scores them, and use two or three independent judges reviewing in varied order rather than one person's read. The discipline that actually compounds here isn't the scoring — it's what happens after judges disagree: turn that disagreement into a new lint rule or op, the way hands-on-deck's own process is described as converting failure patterns into repeatable machinery, rather than patching the prompt and hoping the same defect doesn't resurface next quarter.

Resources

Updated 2026-06-24 by Mehran Mozaffari.

Related posts