Deck Review Skill
Help startup founders strengthen their pitch decks before sending them to investors. Produce a structured, scored review with specific, actionable recommendations grounded in current best practices from Sequoia, DocSend, YC, a16z, and Carta data. The tone is founder-first: a candid coaching session, not a VC evaluation.
Skill Metadata
- Author: lool-ventures
- Version: managed in
founder-skills/.claude-plugin/plugin.json - Compatibility: Python 3.10+ and
uvfor script execution. - Exports:
checklist.json→financial-model-review,ic-sim,fundraise-readiness(future)
Skill Execution Model (READ FIRST)
See
founder-skills/references/skill-execution-model.mdfor the full inline-skill execution model (3 dispatch contexts, Mitigation 1+2, producer contract, Cowork quirks, per-symptom triage).
This skill runs inline in the main thread, not as a sub-agent — see the reference above ("Why Inline (Not Forked Sub-Agent)") for the rationale. Sub-agents are deliberately shell-free, so orchestration (producer scripts, artifact persistence) stays in the main thread.
Two dispatch contexts for the sub-agent:
- Context A — Per-step analytical dispatch (Mitigation 1): Steps 4 and 5 dispatch the deck-review agent via the
Tasktool. The agent does deep analysis, WRITES its output JSON to theOUTPUT_PATHgiven in its prompt (thehandoff/dir), and returns a small receipt. The main thread gates the file withcheck_handoff.py, then pipes it through the producer script (slide_reviews.pyorchecklist.py). The sub-agent never writes canonical artifacts — only its hand-off file. - Context B — Post-compose coaching dispatch: Step 7 dispatches the sub-agent after
compose_report.pywritesreport.md. The sub-agent Reads the stagedcoaching_payload.jsonfrom the hand-off dir (Mitigation 2) — it does NOT Read the fullreport.md— composes the coaching commentary, WRITES it to theOUTPUT_PATHhand-off file, and returns a small receipt. The main thread gates the file (check_handoff.py) and inserts it via the sharedinsert_coaching.pyscript (idempotency matrix, uuid-marker replacement, run_id-parity verification — all deterministic). See the reference above for the full Context B contract.
Tolerant JSON extraction protocol (Context B returns; also the Context A message-channel fallback): capture the sub-agent's final assistant message. It should be raw JSON, but may be wrapped in ```json ... ``` fences or carry a prose preamble. Extract tolerantly:
- If the message is wrapped in a
```json ... ```(or plain``` ... ```) fence, strip the fence first. - Try to parse the stripped text directly as JSON.
- If that fails, walk through the text looking for the first
{character and tryjson.JSONDecoder().raw_decode(text[i:])— this is brace-aware and handles nested objects correctly (unlike regex, which truncates on the first}). - If extraction fails entirely, re-prompt the sub-agent with: "Your previous reply could not be parsed as JSON. Return ONLY the JSON object — no markdown fences, no prose preamble."
Context A receipts don't need this protocol by hand — check_handoff.py --receipt-json - applies the same tolerant extraction internally; pass the final message verbatim.
Input Formats
Accept any format: PDF, PowerPoint (PPTX/PPT), markdown, or text descriptions of slides. PowerPoint is converted to PDF at ingestion (Step 2) so the slides can actually be seen; without a converter the review degrades to text-only and says so.
Available Scripts
All scripts are at ${CLAUDE_PLUGIN_ROOT}/skills/deck-review/scripts/:
setup_run.py— ResolvesREVIEW_DIR, detects resume vs. fresh run, cleans stale artifacts (--clean)deck_inventory.py— Producer fordeck_inventory.json(agent provides JSON via stdin; schema-validated)stage_profile.py— Producer forstage_profile.json;--rebuild-stage+--confidence {high,low}for founder-corrected stagesgate_state.py— Producer (emit) + answer-writer (answer) for the stage-confirmation gateledger.py— Producer forledger.json; refuses a figure whosevaluedisagrees with its ownrawstringreconcile.py— Producer forreconciliation.json; corroborates each figure's quote against the second read, computes the proposed relations, and decides which reach the founderslide_reviews.py— Producer forslide_reviews.json(agent provides JSON via stdin; schema-validated).--reconciliationis required: the numeric chain must have run for this run_idchecklist.py— Scores 35 criteria across 7 categories (pass/fail/warn/not_applicable)compose_report.py— Assembles artifacts into final report with cross-artifact validation;--strictexits 1 on high/medium warningsvisualize.py— Generates self-contained HTML with SVG charts (not JSON)
Also available from ${CLAUDE_PLUGIN_ROOT}/scripts/ (shared):
founder_context.py— Per-company context management (init/read/merge/validate)
Run with: python3 ${CLAUDE_PLUGIN_ROOT}/skills/deck-review/scripts/<script>.py --pretty [args]
Available References
Read as needed from ${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/:
deck-best-practices.md— Full best practices: slide frameworks, stage-specific guidelines, design rules, AI-company requirementschecklist-criteria.md— Definitions for all 35 criteria with pass/fail/warn thresholdsartifact-schemas.md— JSON schemas for all artifacts
Artifact Pipeline
Every review deposits structured JSON artifacts into a working directory. The final step assembles all artifacts into a report and validates consistency. This is not optional.
| Step | Artifact | Producer |
|---|---|---|
| 1 | founder context | founder_context.py read/init |
| 2 | deck_inventory.json |
deck_inventory.py (agent provides JSON via stdin) |
| 3 | stage_profile.json |
stage_profile.py (agent provides JSON via stdin) |
| 3.5 | ledger.json |
ledger.py (agent provides JSON via stdin) |
| 3.6 | second_read.json |
a ledger-blind re-read of the figure-bearing slide text |
| 3.8 | reconciliation.json |
reconcile.py (agent proposes relations via stdin) |
| 4 | slide_reviews.json |
slide_reviews.py (agent provides JSON via stdin) |
| 5 | checklist.json |
checklist.py |
| 6 | Report | compose_report.py (writes both report.json and report.md) |
Rules:
- Deposit each artifact before proceeding to the next step
- For producer-script artifacts (Steps 2-4, including 3.5 and 3.8), the agent supplies JSON on stdin and the script schema-validates against
references/schemas/<artifact>.schema.json. Never write artifacts directly viaWriteorEdit— always pipe through the producer script sometadata.run_idis injected and the schema is enforced. - If a step is not applicable, deposit a stub:
{"skipped": true, "reason": "..."}
Keep the founder informed with brief, plain-language updates at each step. Narrate the founder-visible OUTCOME, never the internal step. That is the test, and it catches more than a word list can: the forbidden thing is not a syntax, it is talking about the machinery. Bad — "Gating and piping the extraction through the producer, then staging the coaching hand-off"; good — "I've checked your numbers and I'm writing up what stood out." Never name an internal artifact, field, or token (a payload key, a marker name, an artifact filename, a hand-off dir) even in plain prose with no backticks — a detector keyed on syntax cannot see "gated", "hand-off" or "canonical artifacts", but the founder still reads them and they mean nothing to them. The between-step progress lines are the primary leak vector, not the final summary. Say the outcome of each transition: "Reading your deck slide by slide", "Your figures line up — moving on to the slide review", "Finishing up and putting the report together". Also excluded: file/script names, paths, *.py, --flags, $vars, exit codes ("Exit N", "not found"), W_/E_ codes, JSON, and step/route labels ("Lane N", "Context A/B", "Phase N", "structure detection", "the grid", any ALL_CAPS_TOKEN). After each analytical step (4–5), share a one-sentence finding before moving on. The task tracker is founder-visible too — the same rule governs its labels. "Gate the slide-review handoff" is a leak even though it names a real step, and even when the prose around it is clean. Label each task by the founder-visible outcome — "Check your inputs", "Score against the review", "Write up what I found" — never by a file, directory, script, or pipeline stage.
Workflow
Step 0: Path Setup
Every Bash tool call runs in a fresh shell — variables do not persist. A stale reference does not error, it silently expands to empty (a path quietly becomes /inputs.json). Run the block below exactly once: it resolves $PLUGIN_ROOT deterministically, and every later block must substitute the printed value as a literal rather than re-running the resolution — repeating the self-heal search can land on a different mount than Step 0 picked when more than one is present (see why in the block's comments). $RUN_ID is minted once below, then re-established authoritatively by setup_run.py's printed run_id (Step 1, which decides resume-vs-fresh) — never re-run the mint line below in a later block. Read the printed values out of each Bash call's output (PLUGIN_ROOT and ARTIFACTS_ROOT here, then review_dir/run_id/resume/gate_answer after Step 1) and paste them as literals into every subsequent block; do not carry a variable forward and assume it survived.
Optional, best-effort, and via the Read tool (not a shell command): before the block below, Read ${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json and note its version field as EXPECT_VERSION. Passing it to select_plugin_root.py below lets an exact version match win over an arbitrary first hit. If the Read fails, skip it and omit --expect-version — selection is still deterministic without it.
SCRIPTS="${CLAUDE_PLUGIN_ROOT}/skills/deck-review/scripts"
if [ ! -d "$SCRIPTS" ]; then
# In Cowork, CLAUDE_PLUGIN_ROOT is a host path absent inside the VM. Collect EVERY
# candidate mount (a session can have several) and let select_plugin_root.py pick one
# deterministically — never trust `find`'s first hit, which mixes plugin versions.
CANDIDATES="$(find /sessions -type d -path '*/skills/deck-review/scripts' 2>/dev/null)"
[ -n "$CANDIDATES" ] || CANDIDATES="$(find / -type d -path '*/skills/deck-review/scripts' 2>/dev/null)"
PROVISIONAL_ROOT="$(printf '%s\n' "$CANDIDATES" | head -1)"
PROVISIONAL_ROOT="${PROVISIONAL_ROOT%/skills/*}"
# Bootstrap order: $SHARED_SCRIPTS isn't known until a root is chosen, so use the
# provisional root's OWN copy of the selector; an older plugin copy without one
# falls back to the provisional root unchanged.
SELECTOR="$PROVISIONAL_ROOT/scripts/select_plugin_root.py"
if [ -f "$SELECTOR" ]; then
if [ -n "$EXPECT_VERSION" ]; then
PLUGIN_ROOT="$(printf '%s\n' "$CANDIDATES" | python3 "$SELECTOR" --expect-version "$EXPECT_VERSION")"
else
PLUGIN_ROOT="$(printf '%s\n' "$CANDIDATES" | python3 "$SELECTOR")"
fi
else
PLUGIN_ROOT="$PROVISIONAL_ROOT"
fi
SCRIPTS="$PLUGIN_ROOT/skills/deck-review/scripts"
fi
PLUGIN_ROOT="${SCRIPTS%/skills/*}"
echo "PLUGIN_ROOT=$PLUGIN_ROOT" # resolved ONCE, here — paste this literal into every later block; never re-run this resolution
REFS="$PLUGIN_ROOT/skills/deck-review/references"
SHARED_SCRIPTS="$PLUGIN_ROOT/scripts"
# Resolve the artifacts root via the SCRIPT, never inline bash: an inline computation gets
# paraphrased into outputs/ one run and outputs/artifacts/ the next, desyncing find_artifact.py.
python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" # prints ARTIFACTS_ROOT — use the printed path verbatim as ARTIFACTS_ROOT in every later block (a captured var dies in the next fresh shell)
# RUN_ID — used by Step 1 (founder_context init) before slug-aware setup_run.py
# runs, then passed to setup_run via --run-id. If the caller's task prompt
# supplied a RUN_ID (resume), keep it; otherwise mint a fresh one.
RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}"
Reaching the self-heal branch is normal in Cowork — ${CLAUDE_PLUGIN_ROOT} resolves to a HOST path that does not exist inside the VM, so the [ ! -d "$SCRIPTS" ] test fails by design rather than by misconfiguration. It is not a sign anything is wrong, and it is not worth narrating to the founder.
Outputs mount is append-only. Everything under the promoted outputs mount (.../mnt/outputs/, not just $REVIEW_DIR) is write-allowed and delete-denied by the platform: never rm, move away, or empty anything under it — including files you created yourself. Never create ad-hoc scratch anywhere under the outputs mount (no _src/ copies, no run-state note files); scratch belongs in $STAGING_DIR (a /tmp dir, defined below). Do not "clean up" the outputs folder before delivering — extra working files there are expected and harmless. The uploaded deck is already readable in place from the uploads mount; never copy it under outputs to make it readable.
There is no quick-check lane here, and that is deliberate. The 35 criteria are scored from per-slide sub-agent reviews — those reviews ARE the work, so dropping them leaves only the checklist scaffolding. So when the founder asks a small conversational question, do not improvise an answer from your own reasoning under this skill's name — an unproduced score is exactly the output a founder over-trusts. Instead, say up front what the full run costs and let them choose: "Answering that properly means running the full deck review — it takes several minutes and produces a scored report across all 35 criteria. I can run it now, or if you just want my read without the scoring, say so and I'll answer outside the deck review." Naming the trade-off is honest; quietly substituting the cheap version is not.
After Step 1 (when the slug is known) — substitute $SLUG below with the company slug from Step 1's printed JSON, then call setup_run.py to resolve REVIEW_DIR, detect whether this is a resume, and clean stale state in one atomic step. Always call setup_run.py with --clean and --run-id "$RUN_ID"; do not pre-read gate_state.json yourself. setup_run.py decides resume vs. fresh by comparing the answered gate_state.json's run_id against --run-id, and on a fresh (non-resume) run it deletes a stale answered gate_state.json so a prior completed run cannot be misread as a resume:
python3 "$SCRIPTS/setup_run.py" \
--artifacts-root "$ARTIFACTS_ROOT" \
--slug "$SLUG" \
--run-id "$RUN_ID" \
--clean \
--pretty
Read review_dir, run_id, resume, reuse_checkpoints, gate_id, gate_action, and gate_answer from the JSON printed by the previous Bash command. gate_action is what to do next — branch on it, not on the answer string. It is one of:
gate_action |
what it means |
|---|---|
continue |
the founder confirmed; proceed |
continue_if_rebuilt |
they said "proceed anyway"; rebuild the profile at low confidence FIRST, then proceed |
rebuild |
an intermediate answer (Different stage, or a stage_choice pick): rebuild and re-emit the confirmation gate — this run is not finished asking |
stop |
the founder declined the review. Stop. Produce nothing. |
reask |
no usable answer; emit the gate |
Read gate_id too when you act on the answer: "Seed" means one thing on stage_choice and nothing at all on the others, and the answer string alone cannot tell you which gate you are resuming. Substitute REVIEW_DIR with the review_dir value, RUN_ID with the run_id value, and IS_RESUMING with 1 if resume is true, else empty, in every subsequent bash block. Then:
# Context A hand-off dir — PER RUN: sub-agents WRITE their raw output JSON here (audit trail,
# pre-validation; never a canonical artifact). The $RUN_ID segment is load-bearing — it stops a
# stale prior-run file passing the hand-off gate when a dispatch fails to write.
HANDOFF_DIR="$REVIEW_DIR/handoff/$RUN_ID"
mkdir -p "$HANDOFF_DIR"
# Sub-agents address the SAME dir by a different path (their file tools are rooted at the outputs
# mount in Cowork). Resolve agent-namespace paths via the script — never hand-splice the printed
# root with a literal skill/slug/run-id string, which is the non-determinism it exists to remove:
python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" --handoff-dir-agent \
--dir-name "deck-review-${SLUG}" --run-id "$RUN_ID" # prints HANDOFF_AGENT verbatim
HANDOFF_AGENT="<printed value>" # use verbatim in OUTPUT_PATH lines
# Sub-agent READ paths for under-outputs artifacts use the SAME agent namespace (relative — the
# sub-agent's file-tool cwd IS the outputs mount on host-loop; an absolute /sessions/... read is denied):
python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" --analysis-dir-agent \
--dir-name "deck-review-${SLUG}" # prints the dir in the agent namespace
REVIEW_DIR_AGENT="<printed value>" # e.g. stage_profile.json, deck_inventory.json reads
# Ad-hoc scratch (NOT hand-off) lives OUTSIDE the outputs/ tree, where it is safe to create and
# reclaim. Use the printed path verbatim in later steps.
STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/deck-review-${SLUG:-deck}.staging.XXXXXX")"
To resume across a gate round-trip, the caller's task prompt must supply the prior RUN_ID (so RUN_ID above is set before this block runs). Then setup_run.py sees the answered gate_state.json whose run_id matches and returns resume: true — and because resume is true, --clean leaves gate_state.json, deck_inventory.json, and stage_profile.json in place (they are same-run checkpoints for this RUN_ID).
Pass RUN_ID to every producer script via --run-id. Producer scripts inject it into metadata.run_id automatically. compose_report.py enforces that all required artifacts share the same run_id and emits a MISSING_METADATA (high) warning for any artifact without one. Keeping RUN_ID stable across the gate is what prevents a STALE_ARTIFACT mismatch with the pre-gate artifacts.
When reuse_checkpoints is true: gate_state.json, deck_inventory.json, and stage_profile.json survived --clean because they belong to THIS run. Skip Steps 2 and 3 if both deck_inventory.json and stage_profile.json exist and their metadata.run_id matches $RUN_ID; otherwise re-run them with the same RUN_ID.
Read reuse_checkpoints, not resume, for this decision — they are different questions and they do come apart. resume says the gate may be skipped; reuse_checkpoints says the artifacts on disk are this run's. A same-run gate answered without a recorded source yields resume: false with reuse_checkpoints: true: ask the founder again, but keep what Steps 2-3 already produced. Keying the skip on resume re-runs and overwrites them, spending the three dispatches the preservation exists to protect. Apply the same rule to Steps 3.5-3.8: skip them when reconciliation.json exists with a matching metadata.run_id. It is the most expensive stretch of the pipeline — three dispatches, two of which read the deck — and re-running it on a gate round-trip spends that twice for an identical result.
Step 1: Read or Create Founder Context
python3 "$SHARED_SCRIPTS/founder_context.py" read --artifacts-root "$ARTIFACTS_ROOT" --pretty
Exit 0 (found): Use the company slug and pre-filled fields. Proceed to Step 2.
Exit 1 (not found): Expected on a first run — do NOT mention this check or its exit status to the founder; if you narrate anything first, say only "Let me grab a few basics about the company." First skim the attached deck (title slide, footer, contact block — a Read of the file is available now; do NOT write any artifact yet) to derive candidate values. Then use AskUserQuestion (NOT plain chat) to ask for company name, stage, sector, and geography, pre-filling each question's first option with the deck-derived value (company name from the title slide; sector/geography from deck signals such as customer names, currency, phone country codes), labeled as read from the deck; keep free-form options for correction. If AskUserQuestion is genuinely unavailable in the host, do NOT skip the ask and do NOT assume the answer: ask the same question in plain chat, state the options explicitly, and wait for an answer before continuing. The ban above is on asking casually WHILE the tool is available — it is not a reason to stall a host that lacks it. If the deck yields no signal for a field, ask as normal. Deriving some of the four does not license skipping the ask. Treat them independently: a deck that evidences company, stage and sector but says nothing about geography leaves you asking for geography — not filling it in and moving on. Never record a value the materials do not evidence, and in particular never read geography off a currency symbol ($ is also CAD, AUD and SGD, and founders everywhere price in USD) or off where the team used to work (an ex-Stripe engineer is not a US company). Geography selects which regulatory and benchmark guidance the whole review is graded against, so a silent guess there is not a small one.
Stage is the exception to deck-derived pre-filling — it has a real fixed label set, not a value to read off a slide.
Options: Pre-seed / Seed / Series A / Series B+
→ pre-seed | seed | series-a | series-b (founder_context.py's VALID_STAGES has 7 values including series-c/series-d/later; on a Series B+ pick, ask a plain-text follow-up rather than defaulting to series-b). This is founder_context.py's company-stage field, distinct from the deck-scope --rebuild-stage enum the later Gate uses (pre_seed/seed/series_a/series_b/growth, at :377 below) — the two do not share a value set. Provide at least 2 options. Stage is re-confirmed later by the Gate, so Step 1's stage answer is a prior, not a commitment. Then create:
python3 "$SHARED_SCRIPTS/founder_context.py" init \
--company-name "Acme Corp" --stage seed --sector "B2B SaaS" \
--geography "US" --artifacts-root "$ARTIFACTS_ROOT" \
--run-id "$RUN_ID"
Exit 2 (multiple): Present the list, ask which company, re-read with --slug.
Execution checkpoint — END OF STEP 1, READ BEFORE CONTINUING
You now have enough to run. Invoking this skill is not the same as running it. From here, every number that reaches the founder must come out of a producer script. Concretely:
- Never compute a figure in chat. Not TAM, not runway, not a ratio, not a benchmark comparison — not even one you are confident about. An in-chat number has no provenance, no range, no artifact, and nothing downstream can contradict it. That is worse than a slow answer and worse than no answer.
- Never benchmark against a figure you recalled. Benchmarks live in the reference files and the producers read them. If you find yourself writing "typically around X for this stage", stop: either a producer sourced it or it does not go in front of the founder.
- A what-if, a sensitivity illustration, or "roughly what would X give" is NOT an exemption. This is the exemption a live run invented: having correctly produced the real figure, it then wrote "using the current count would shave TAM to roughly €249M rather than €270M" — a second number, computed in chat, from an input the founder never gave. An illustrative figure is read exactly as confidently as a computed one, and the founder cannot tell which came from the pipeline. Two ways to answer a what-if: re-run the producer with the alternate input and quote its output, or give no number and say which direction it moves. Never arithmetic in prose.
- Never offer the real run as an opt-in after answering. "Here's a rough estimate — I can run the full analysis if you want" is the failure. The founder cannot tell that what they just read was not the analysis, so they will not ask for it.
- Two ways to finish, and only two: run the full pipeline to completion, or run the full pipeline after stating its cost up front (there is no quick lane here). Both end with real artifacts on disk. Anything else is not a finished run.
- If you are blocked, say BLOCKED and say why. A missing input, a failed hand-off, an unreadable document — name it and stop. Do not substitute your own reasoning for the pipeline and present the result as its output.
Artifact existence is the proof of execution: if no canonical artifact was written, the skill did not run, whatever the transcript says.
Step 2: Ingest Deck -> deck_inventory.json
Ingestion pitfalls — common issues that degrade review quality:
- PDF image-only slides: Some PDFs embed slides as images with no extractable text. If Read returns blank or garbled content, note
input_quality: "image_only"indeck_inventory.jsonand base the review on visual description + OCR-level best effort. Flag reduced confidence in coaching commentary. - PPTX speaker notes vs. slide content: Speaker notes often contain the real narrative; slide text is abbreviated. Extract both — notes go into
content_summary, slide text intoheadline. Do not discard notes. - Multi-file submissions: Founder sends v1 + v2, or deck + appendix as separate files. Ask which is the primary deck before proceeding. Do not merge or review both simultaneously.
- Partial decks: Deck has fewer than 5 slides or is clearly a subset. Proceed but set
confidence: "low"in stage_profile and note the limitation. Missing-slides detection still runs normally. - Wrong file type: File named
.pdfbut is actually a Word doc or image. If Read fails, try alternate format before asking the founder for a re-upload.
When the deck is image-rendered, deck_inventory IS the canonical text. For a PDF whose
slides are images, Read returns page images and there is no extracted text to inline — so build
the per-slide record here once (headline / content_summary / visuals) and inline THAT, verbatim
and identically, everywhere a dispatch below asks for the deck's text. Do not let each dispatch
re-transcribe: LEDGER_EXTRACTION and SECOND_READ are the two halves of one corroboration, and
two different transcriptions make the second read a second read of a different deck — which
weakens the check silently instead of failing it.
Find the deck before anything else — do not assume it is missing. An attached file is already on disk under the uploads mount; nothing tells you its name up front, so list it:
python3 "<printed PLUGIN_ROOT>/scripts/resolve_artifacts_root.py" --uploads # prints UPLOADS_DIR
Then ls -la <printed UPLOADS_DIR>. Measured: on one run the agent never looked, replied "I don't
see a pitch deck attached", and stopped — with the deck sitting in the uploads mount the whole
time. Only ask the founder to upload after that listing actually comes back empty. Set DECK_SRC
to the file you find. Exit 3 means there is no uploads mount at all (not an empty one): ask for a
path rather than reporting the deck missing. Never hand-build this path — a relative ./mnt/uploads
resolves against the shell's cwd, which has already moved once underneath us.
Convert a PowerPoint deck to PDF before reading it. Design & Readability are scored from
what a reader SEES, and only a rendered page gives you that. Today .pptx/.ppt are binary
and Read refuses them outright, so without this the slides are invisible — but do not treat
that as the reason: if some future Read does open PowerPoint, still convert unless it returns
actual page images, because text and structure without layout cannot support a design score.
Do this FIRST, before reading anything. Substitute the uploaded deck's path for <deck path>:
DECK_SRC="<deck path>"
DECK_READ="$DECK_SRC"; SOFFICE=""
case "$DECK_SRC" in
*.pptx|*.PPTX|*.ppt|*.PPT)
DECK_READ="no-converter"
for c in libreoffice soffice /Applications/LibreOffice.app/Contents/MacOS/soffice; do
command -v "$c" >/dev/null 2>&1 && { SOFFICE="$c"; break; }
done
if [ -n "$SOFFICE" ]; then
# -env:UserInstallation is REQUIRED: $HOME is read-only, so profile creation
# dies (exit 77) having converted nothing. Do not suppress errors — a silent
# failure is indistinguishable from having no converter, and misreports why.
"$SOFFICE" --headless -env:UserInstallation="file://$STAGING_DIR/.lo" \
--convert-to pdf --outdir "$STAGING_DIR" "$DECK_SRC" 2>&1 | tail -3
B="$(basename "$DECK_SRC")"
if [ -s "$STAGING_DIR/${B%.*}.pdf" ]; then
DECK_READ="$STAGING_DIR/${B%.*}.pdf"
else
DECK_READ="convert-failed"
fi
fi
;;
esac
echo "$DECK_READ"
Then branch on what it printed:
- A path — read THAT file with the Read tool's
pagesparameter, exactly as for any PDF, and setinput_formatto"pptx". The slides are now genuinely visible, so the Design & Readability criteria are scored normally. convert-failed— a converter exists and broke; its error printed just above. Report that error verbatim when you tell the founder what happened, then take the same fallback asno-converterbelow. Do not retry blindly.no-converter(orconvert-failed) — you cannot see the slides, so do not review them as if you could. Runpython3 "$SHARED_SCRIPTS/pptx_to_text.py" "$DECK_SRC" --prettyand read the JSON straight from the command's output — do NOT write it to$STAGING_DIRand Read it back, because$STAGING_DIRis a/tmppath outside the session and the Read tool refuses it. Then build the inventory from that (it carries speaker notes, which often hold the real narrative), and setinput_formatto"text"— which gates the 4 visual Design & Readability criteria tonot_applicable. Scoring a deck's layout without having seen it is a confident review of something you never looked at. Tell the founder you read the content but could not see the design, mentionimages_not_readif non-zero, and that a PDF gets the full review. If the script also fails, ask for a PDF re-export and do not proceed.
Read EVERY page, and record whether you actually saw it. Read takes at most 20 pages
per call, so a deck longer than that needs several calls — read pages 1-20, then 21-40, and
so on until the last page. A deck partially read is the failure this record exists to catch:
design criteria are scored from what a reader SEES, and a slide nobody rendered cannot
support that judgement any more than a PowerPoint nobody converted can.
Two fields carry the record, and both are load-bearing rather than bookkeeping:
input_quality(required):"good"when every page rendered and was legible;"image_only"when slides are pictures with no extractable text;"partial"when any page went unread. It is required precisely because its absence used to be indistinguishable from"good"— a review of a deck nobody could read looked identical to a review of one that was read."image_only"and"partial"gate the 4 visual Design & Readability criteria tonot_applicableautomatically, the same way"text"does.visual_evidence_captured(per slide):truewhen you rendered and saw that slide,falsewhen you have only its text.
Read the provided deck. For each slide, extract: headline, content summary, visuals description, word count estimate. Also determine ai_company_status using the two sub-questions below. Then write the inventory through the producer script:
AI company classification (mandatory — field is required): Answer two sub-questions:
- Does the deck make an AI claim? (tagline, "AI-native"/"AI-powered" positioning, or AI in the product description)
- Is there evidence AI is core? Use ALL FOUR signals: ML in value prop / inference-or-training in COGS / foundation-model or fine-tuning mentions / AI-specific retention metrics.
Map to ai_company_status:
- Evidence present (any core-AI signal) →
"ai_core" - AI claim but no core-AI evidence →
"ai_claimed_unverified" - No AI claim and not AI →
"not_ai"
Record what evidence or claim was found in ai_evidence (required for ai_core and ai_claimed_unverified; brief for not_ai).
claimed_stage holds the stage token the deck itself states (pre_seed, seed, series_a, series_b, growth). If the deck never states a stage, omit the field or set it to null — never invent a descriptive placeholder (a made-up value misfires the stage cross-checks downstream).
claimed_raise, ai_evidence and slides[].visuals are optional: null and omission mean the same thing — the producer normalises an explicit null away before validating, so either spelling is accepted. Prefer omission. A deck that states no ask is a real and notable finding, so say so in the review rather than treating the empty field as the whole story.
cat <<'INVENTORY_EOF' | python3 "$SCRIPTS/deck_inventory.py" --run-id "$RUN_ID" -o "$REVIEW_DIR/deck_inventory.json" --pretty
{
"company_name": "...",
"review_date": "YYYY-MM-DD",
"input_format": "pdf",
"input_quality": "good",
"total_slides": 12,
"claimed_stage": "seed",
"claimed_raise": "...",
"ai_company_status": "...",
"ai_evidence": "...",
"slides": [
{"number": 1, "headline": "...", "content_summary": "...", "visuals": "...", "word_count_estimate": 15, "visual_evidence_captured": true}
]
}
INVENTORY_EOF
The script validates against references/schemas/deck_inventory.schema.json and injects metadata.run_id. Never write deck_inventory.json directly via heredoc — the schema-validation gate is what keeps the pipeline honest.
Step 3: Detect Stage -> stage_profile.json
Determine pre-seed/seed/series-a from signals in the deck. Read references/deck-best-practices.md for stage-specific frameworks. Record: detected stage, confidence, evidence, whether AI company, expected slide framework, stage benchmarks.
Stage signals: Pre-seed: no revenue, LOIs/waitlist, prototype, <$2.5M ask. Seed: early ARR, paying customers, <$6M ask. Series A: $1M+ ARR, cohort data, repeatable GTM, $10M+ ask. Later-stage: set detected_stage to "series_b" or "growth" — use the Gate below. Do not ask outside the gate.
AI company note: ai_company_status was determined in Step 2 (deck inventory) and is already in deck_inventory.json. Set is_ai_company in stage_profile.json to true if ai_company_status is "ai_core" or "ai_claimed_unverified", otherwise false. Record the same evidence in ai_evidence.
Then write the profile through the producer script:
cat <<'PROFILE_EOF' | python3 "$SCRIPTS/stage_profile.py" --run-id "$RUN_ID" -o "$REVIEW_DIR/stage_profile.json" --pretty
{
"detected_stage": "seed",
"confidence": "high",
"evidence": ["Claims $2M ARR", "..."],
"is_ai_company": false,
"ai_evidence": "...",
"expected_framework": ["..."],
"stage_benchmarks": {"round_size_range": "...", "expected_traction": "...", "runway_expectation": "..."},
"reference_file_read": ["deck-best-practices.md", "checklist-criteria.md", "artifact-schemas.md"]
}
PROFILE_EOF
Gate: Confirm Stage and Scope
Sub-agent execution model: sub-agents in Cowork cannot reliably call AskUserQuestion. The gate uses a checkpoint-and-resume pattern — the sub-agent writes a gate_state.json to disk and emits a structured needs_input payload as its final message. The parent (main thread or invoking agent) calls AskUserQuestion if available — offering the needs_input options in the ORDER they appear, first to last, and adding no recommendation of your own — or otherwise asks the founder via plain text — then writes the answer back into gate_state.json with gate_state.py answer (use exactly this flag shape):
python3 "$SCRIPTS/gate_state.py" answer \
--file "$REVIEW_DIR/gate_state.json" \
--run-id "$RUN_ID" \
--answer "<the founder's chosen option, verbatim>" \
--source founder
then re-invokes this sub-agent. (--file, --run-id, --answer, --source; -o/--output are accepted as aliases for --file, and --run-id is checked for parity against the gate's metadata.run_id.) --source is required and says who produced the answer: founder here, because they were asked and replied. (The plain-text round-trip works correctly even without AskUserQuestion.)
How to detect re-invocation: you already did, in Step 1. setup_run.py printed resume, gate_action and gate_answer. If resume was true, skip the gate-emit and jump to "After the gate" below, branching on gate_action (the answer string is context, not the decision). Do not re-read gate_state.json to decide this — resume detection lives in setup_run.py and nowhere else, because it weighs run_id parity and whether the answer records where it came from. This file used to carry a second copy that checked only the first two, so an answer setup_run.py had declined to resume on was acted on regardless.
Auto-satisfy branch — the founder already told you the stage in Step 1. If Step 1's AskUserQuestion
captured a stage and the detected stage MATCHES it, do not ask again: write that answer straight through —
gate_state.py emit the gate exactly as below, then immediately gate_state.py answer it with
--answer "Looks right" --source auto_satisfied — and continue to "After the gate". Re-asking a
question the founder answered two minutes ago reads as not listening, and it is the single most common
reason a founder abandons a gated run.
--source auto_satisfied is what makes this branch auditable afterwards. It is accepted only here —
only on the stage_confirmation gate, only for "Looks right" — because any other gate or option is a
decision the founder has not made, and the script refuses it.
Three conditions, all required, and none is optional:
- It must MATCH. If Step 1 says seed and detection says Series A, that disagreement is exactly what the gate exists to surface — emit it normally and let the founder adjudicate.
- The DECK must agree too. The match above is two-way — what the founder said, and what you
detected — and the deck's own claim is a third party to it. If
deck_inventory.claimed_stagenames a different stage from the one being confirmed, ask: a founder naming their stage from memory may not know their own deck contradicts it, and this gate is the moment that matters.gate_state.py answerrefuses--source auto_satisfiedhere, so this is enforced rather than requested; answer it--source founderonce they have been asked. - There must BE a Step 1 answer. A form the founder skipped, dismissed, or left on its default is not an answer, and neither is a stage you inferred while Step 1 ran. In each case this branch is unavailable — emit the gate normally. The ask need not have been a structured form; a plain-text question they answered counts. What is required is that a founder actually said it.
- Say that you did it. The founder must see "you told me seed, and the deck agrees — proceeding on that" rather than the step silently vanishing. That sentence is only true when the deck does agree, which is why it cannot be reached otherwise. A gate that self-answers invisibly is indistinguishable from a gate that was skipped.
Anything other than a clean match — no Step 1 answer, a mismatch, or low-confidence detection —
…(truncated)