Decks (AI presentation builder)
Decks is the AI presentation builder in Omniscio: describe a deck in plain language, approve the outline it returns, then get editable cards with real layouts, images and speaker notes that you can reorder, present, publish as a web link, or download as PDF, HTML or PowerPoint.
What it is
Decks is a Gamma-style AI presentation builder inside Omniscio,
surfaced as a virtual project in the sidebar. You describe a deck in plain
language; Claude returns an editable outline (titles + one-line summaries);
you approve it; then Claude generates each card — real rich content plus a
chosen layout from a fixed library — with a real narrative arc and auto
speaker notes. The result is an ordered list of cards you can reorder, edit,
present, publish to a shareable web link, and download as PDF, HTML, or
PowerPoint (.pptx).
Slides show real images when the model supplies one, over a deterministic generative-art base (so nothing ever looks empty) — see Real images + generative art. A presenter view (notes + next slide + timer) and a static PDF / HTML / PowerPoint export round it out.
A deck is an ordered list of cards. Each card holds normal rich content (headings, paragraphs, bullets, image) plus two presentation attributes: a layout name (from the finite registry) and an optional style (accent-image position, full-bleed, vertical alignment). This is the unit the generator emits and the editor renders.
Status
Ships the data model, the two-stage AI generation pipeline (idempotent, with a cost
cap + progress + resilient parsing + atomic insert; now narrative-arc prompts + auto
speaker notes), the context pull (build a deck FROM a picked session/project/board: read,
clean, bound, distill into an editable grounded brief, with durable source provenance),
an optional pre-outline interview (a bounded Q&A that synthesizes a
brief to seed the outline), the 13-layout registry + 7 themes, the full IPC surface, the renderer
panel (deck list, create flow, card editor with reorder, inline edit + notes editing,
add / delete / restore card, export menu), real images + deterministic generative
art, a presenter overlay (notes + next slide + timer), publish-to-web (the
deck-render canonical viewer via the shared slide engine), and PDF / HTML export
(static print render → htmlToPdf, desktop-only). Still tracked as follow-ups:
paid AI image generation (a non-Anthropic image provider), PowerPoint export, a
WYSIWYG canvas editor, and editable-outline-affects-generation. Decks remains gated
in-development behind the Lab flag.
Where to find it
Decks lives in the left sidebar as its own virtual project, so there is no panel to hunt for — turning it on adds the row, and turning it off removes it. This section covers how it is enabled, how it can also run as an isolated plugin island, and what that plugin form can and cannot do compared with the built-in panel.
How it is gated
Decks is a first-party built-in plugin (src/plugins/decks/manifest.json,
plugin.id: 'decks'), enabled or disabled in Settings → Plugins like any other
plugin. It is off by default (enabledPlugins starts empty) and is NOT gated by
an unreleased-feature flag.
Enabling it creates the __plugin_decks__ sidebar row (via the standard
ensurePluginSidebarRow) and mounts the native DecksView; disabling it hides the
row. The main-process CLI read routes mirror the same state — they 403 unless
settings.enabledPlugins includes 'decks'. The panel renders natively (not as
a webview) through the DECKS_PLUGIN_ID special-case in the dashboard layouts
(mirroring NightyTidy 2); the manifest declares a ui.entryPoint but no island
bundle is committed in this repo (see the bundle note below), so the native panel
is all that renders on this path. Invariants:
decks-plugin-contract.md.
The webview flip (decks-webview) — ON by default since 2026-07-30, with the toggle still live.
A second, independent toggle routes the Decks panel to the standalone sandboxed webview
island (the "detach") instead of the native panel, so Decks can be exercised as a real
plugin. It is the decks-webview unreleased feature — setting decksWebviewEnabled, revealed
via Settings → Lab or AMC_SHOW_DECKS_WEBVIEW=1 — and it defaults ON, so a default user
gets the island and turning the setting off is what returns you to the native panel. It used to be
devOnly (hard-hidden in a packaged build); that hard-hide was dropped once a packaged smoke
proved the real ui/ bundle ships and a CDP probe proved it mounts and renders, so the toggle
now appears in a normal install. ⚠️ The webview and the native panel read DIFFERENT data
stores, so switching either way makes the other side's decks appear to vanish — they are NOT
deleted, and switching back restores them. Desktop-only: the flag has no mobile consumer and an
Electron <webview> cannot run on the mobile web client, so the toggle shows on a phone but
changes nothing there. Invariants the-native-path-remains-the-revert / reachable-in-a-package-is-not-enabled in the same contract.
What the plugin path can and cannot do (feature parity, 2026-07-29). The island is NOT yet a
like-for-like replacement for the native panel, and the gap matters if Decks is published to the
marketplace. SIX host capabilities are exposed to it as bridge calls, so it WRAPS the host
implementation instead of re-implementing it: decks.measureArtifact (the deterministic overflow
gate that repairs text clipped past the slide frame — but only up to a point, see below),
decks.renderHtml (the deck→HTML
renderers — what makes Export PDF / Export HTML / Publish work at all; before it landed all
three threw, while their mocked tests stayed green), decks.exportPptx (PowerPoint export,
added 2026-07-29), decks.assembleFile (extract + distill a document), decks.researchTopic
(the web-search grounding pass) and decks.assembleBoardContext (read + distill a shared
board, added 2026-07-29). All six answer a discriminated
{ ok: true, … } | { ok: false, reason } so a failure can never be misread as a clean result —
see plugin-bridge-hardening-contract.md
measureartifact-reports-whether-it-measured / renderhtml-reports-whether-it-rendered and decks-plugin-contract.md
the-host-writes-the-powerpoint-file (I34, in the export/publish contract) / I35.
exportPptx is the one that SAVES rather than returning data — a .pptx is a binary zip and the
generic export.saveFile writes utf-8 only, so the host pops the Save dialog and writes the file,
exactly as export.savePdf does. It carries a THIRD arm the others do not need: a user who
cancels the dialog gets { ok: true, saved: false }, because a cancel genuinely ran and genuinely
did not fail. On a real save it reports rendered / total, and the plugin tells you when the
saved deck came back SHORT of real slides — the per-slide render is fail-open by design, so a deck
carrying placeholders is a real outcome, not an exotic edge.
assembleFile and researchTopic are the CREATE-side pair. Office extraction is main-process code
and a PDF rides to Claude as a native document block, neither of which the text-only AI door can
express; and research is a tool_choice: auto call carrying the web_search SERVER tool, which the
forced-tool AI door structurally cannot issue. Both are bridged for that reason and no other.
So the island now starts a deck ALL FOUR ways — a typed prompt, from a file, a guided interview, and from your work (a past session, project or board you have shared with it) — each optionally grounded by web research, and it reaches all three export formats (PDF, web page, PowerPoint). The interview needed no new bridge method at all: it is a forced structured-output tool call, exactly the shape the existing AI door already serves.
Boards needed a SEPARATE method rather than a third value on assembleContext's sourceKind,
and the reason is worth knowing because it looks like extra work for nothing: the host gates
permissions per method, not per argument. A board accepted by assembleContext would have
been read under the session-history permission — so a plugin holding only that permission could
have read your boards. One method, one permission, one honest sentence.
"From your work" behaves differently from the built-in panel, and that difference IS the privacy model, not a limitation. The panel can list every session and project you have, because it is the app. The plugin can only list what you have explicitly shared with it — so its picker starts empty, with a "Choose what Decks can read" button that opens Omniscio's own picker. You choose there; Decks never browses your work to offer it to you. Two consequences worth knowing:
- Sharing a project shares its sessions, but sharing one session does NOT share its project. The asymmetry is deliberate.
- Sharing a board shares only that board. Boards sit inside a workspace, and sharing one does NOT share the others — a workspace is far wider than the thing you actually pointed at.
- Decks never sees your messages, or your board's items. It hands Omniscio an id and gets back only a distilled summary, so the underlying content never enters the plugin. That is structural — there is no path for it, not merely a rule being followed.
- Boards asked separately, and on purpose. Decks requests a distinct "read your boards" permission rather than reusing the session-history one, because a board is project-management data and not a conversation. Agreeing to share past sessions should never quietly hand over something else.
- You can withdraw any of it later in Settings → Plugins, where every grant is listed together — sessions, projects and boards in one place, each labelled for what it is. A grant whose target you have since deleted still appears there, so you can always withdraw it.
ONE capability remains host-only:
- The AI visual-review pass is STRUCTURALLY BLOCKED. The bridge's AI door is text-only (no image parameter), and there is an unanswered spend question on top. It cannot be built as things stand, so a published listing must say so: a deck made through the plugin does not get the visual-review pass a native-panel deck gets.
Two things about the plugin's create path differ from the built-in panel, and both are deliberate. First, the plugin refuses a larger file than the panel does (~24 MB vs 25 MB): a file crosses the plugin bridge as base64 inside a 32 MiB-capped payload, so the honest ceiling is lower, and the plugin says so up front rather than failing after the read. Second, web research bills a different budget from the slides: research and file distillation come out of the deck budget, while the outline and cards come out of the plugin's AI budget. Those two can run out independently — so if research is skipped because its budget is gone, the plugin tells you at the outline review, before it spends anything on slides, and tells you again on the finished deck. "Budget used up", "found nothing useful" and "couldn't run" are three different messages, not one.
The overflow gate guarantees a MEASURED slide, not a FITTING one (2026-07-28). Its repair is
bounded on purpose — 6% per pass, two passes — so it rescues a slide that is slightly too full and
deliberately leaves a badly overflowing one alone. Treat it as a safety net for near-misses, not
a guarantee every slide fits. This was proven on a real render, and proving it exposed a defect
worth knowing about: because the bridge used to infer its violation count from "did the card
change?", an unfixable slide came back unchanged and reported violations: 0 — "measured and
clean", for the worst slides in the deck — while the gate logged nothing at all in that case. The
host now REPORTS { status, violations, repaired } and nothing downstream infers it; the unfixable
case logs; and the island tells the AUTHOR (a line on the deck-ready panel, attention-toned,
non-blocking, fired only for measured-and-not-fixed). Details in
decks-plugin-marketplace-publish-preconditions-detach-build-deck-contract.md's
"the NATIVE panel does not tell its author about an unfixable slide" section. ⚠️ The
NATIVE panel still discards those counts, so it does not yet tell its author — the one place the
built-in app is now behind the plugin. The end-to-end proof is npm run decks:measure-proof; it is
a script and not a vitest test because the gate needs a real BrowserWindow, which the unit lane
cannot construct.
⚠️ NO island bundle is committed in this repo — the packaged tree is where one exists.
src/plugins/decks/ui/ is absent from the tree: the plugin's own .gitignore excludes it with an
anchored /ui/ line, and the manifest only declares the entry point (ui.entryPoint: ui/index.html). Nothing in the root build emits it either — the island is built by the plugin's own
pipeline in its own repository, and the packaged app carries the result under
<resources>/plugins/decks/ui/. The invariant is
decks-plugin-marketplace-publish-preconditions-detach-build-deck-contract.md's
committed-bundle-matches-source: no build artifact of the island is committed here, so there is no
stale copy in this repo to drift and no stamp to keep. What replaces that guarantee here is the
release tripwire scripts/checks/packaged-decks-bundle-smoke.mjs (npm run decks:smoke:packaged),
which reads the PACKAGED tree's manifest + ui/index.html and fails rather than skips when the
bundle is missing or is the inert stub.
⚠️ Do NOT verify a rebuild by grepping the bundle for identifiers. It is minified, so local
names are mangled away and a bare presence check reports a SUCCESSFUL rebuild as a failure. Only
what a minifier cannot safely rename survives — bridge method names read off the host API object,
store keys, object properties (measureArtifact, renderHtml, overflowingCount,
preferCSSPageSize). Host-side-only strings such as the gate's not-measured status never cross
into the island at all, whatever the freshness. Use an OLD→NEW differential on surviving markers,
never a presence check on arbitrary symbols.
Users who had the older Decks Lab toggle (decksEnabled) on are carried over to
the enabled plugin by a one-time startup migration (decksEnabled → enabledPlugins),
so nobody loses access on upgrade.
How it behaves
The two-stage flow is what keeps you in control: nothing is generated until you have approved an outline, and a deck can start from a typed prompt, a file you insert, a guided interview, or work you have already done.
The two-stage generation flow
Generation is deliberately two-stage so you stay in control and never pay for a full deck you didn't approve. An optional pre-outline interview (see below) can run first to enrich the prompt:
- Outline —
decks:generate-outline { prompt, numCards, tone?, audience?, brief? }calls Claude for a structured outline ({ title, cards: [{ title, summary, intent, suggestedLayout }] }), creates the deck shell, sets its status to'outline', and stages the outline in memory keyed bydeckId. Returns{ deckId, outline }. Nothing is fully generated yet. Abrief(from the interview) is folded into the generation prompt so the deck is tailored to it. - Cards —
decks:generate-cards { deckId }reads the staged outline, sets status'generating', generates each card's content + layout in a bounded-concurrency pass (emittingdecks:generation-progressas it goes), persists the cards, and flips status to'ready'. On failure the deck goes to'error'and the progress push reportsstatus: 'error'.
The staged outline is process-local (a restart mid-flow just means you re-generate the outline) — never a durable claim.
Reliability: idempotent, resilient, atomic
Both paid stages are hardened so they never double-charge or half-build a deck (see the decks contract for the test-locked invariants):
- Idempotent generate (no double-spend). A duplicate delivery of either paid
call — a double-click, a WebSocket replay, a retry — charges exactly once. The
outline path dedups on a renderer-minted
clientRequestId; the cards path dedups ondeckId. Concurrent duplicates share one in-flight run; a failure is never cached (the next attempt re-runs). - Resilient parsing (one bad block never kills the deck). Card output is
salvaged, not strictly rejected: an unknown layout falls back to
text, a bad or over-long block is dropped or truncated to the schema caps, and a fully-empty card becomes an explicit placeholder so generation always completes. The outline only fails if not a single card is usable. - Atomic card insert. Generated cards are written in one transaction — a mid-batch failure rolls the whole batch back, so a deck is never left half-built.
Build from your work — the context pull
The create screen's source switch (Prompt | From a file | From a session | From a project | From a board) lets a deck start from a file you insert, from work you already did in Omniscio, or from a Mission Control board mirrored locally, instead of a blank prompt:
- Pick a source. The session picker lists your 50 most recent sessions (silent recipe lanes and unresolvable projects filtered out); the project picker uses the standard project select.
- Read my work.
decks:assemble-context { sourceKind, sourceId }runs the four-stage assemble indeck-context-service.ts: READ the transcript(s) (getFullSessionHistory/listSessions— main process only), CLEAN agent noise (stripAgentNoise, the continuous-summary filter), BOUND to thedecksContextTokenBudgetsetting (default 20k tokens; head + tail kept, middle dropped with an omission marker; project mode allocates newest-first and reportscoverage— "Included N of M sessions"), then DISTILL with one metered call (cost labeldecks-context, same daily cap) into{ suggestedTitle, suggestedAudience, brief }. - Edit the brief. The response seeds an editable "What I understood from your work" textarea (≤ 8000 chars) — the faithfulness guard: you see and correct what the deck will be grounded in before anything is generated. An unusable distill falls back to a grounded no-AI brief; an empty/unknown source is a friendly error before any paid call.
- Generate. The edited brief flows into the EXISTING
decks:generate-outline { …, brief, sourceRef }seam. The deck row persists its origin (decks.source_ref_json), and the editor shows a quiet "from source" pill. A duplicate delivery of the paid assemble dedups onclientRequestIdexactly like the outline path.
From a file is the fourth source: you insert a PDF / Word / PowerPoint / Excel / CSV /
text file (decks:assemble-file { filename, base64, mimeType }) and deck-file-extract.ts
turns it into text — office docs via the shared extractOfficeText, text via UTF-8, and a
PDF handed to Claude as a native document block (no in-process PDF parse) — which
assembleFileContext runs through the SAME BOUND → DISTILL → editable-brief → outline path
above, with the same decks-context cost label + cap, clientRequestId dedup, and
sourceRef.kind: 'file' provenance. It reuses partitionAttachments for the type taxonomy
and the renderer's readFileAsDataUrl + useFileDropZone; an unsupported / oversized /
empty file is a friendly error before any paid call.
From a board is the fifth source: you pick a Mission Control board from the local SQLite
mirror (decks:assemble-context { sourceKind: 'board', sourceId }) and
deck-context-service.ts reads the board's groups, items, columns, and column values
via the PM query layer (the pm-queries-{group,item,column}.ts leaf modules). The formatter (deck-board-formatter.ts)
renders each group's items as a status-report-style document with column-type-aware
formatting — status/priority resolve their display labels from the column settings JSON,
dates and timelines parse their JSON structure, people use the text field. All formatters
have try/catch fallbacks for corrupt JSON. Items are capped at BOARD_ITEM_LIMIT (200)
with honest coverage reporting (includedItems/totalItems/truncated). The distill uses a
board-specific system prompt oriented toward status reports (not the transcript prompt).
The tab only appears when BOTH decksEnabled AND missionControlEnabled are on. The board picker
uses a PM_LIST_BOARDS IPC channel (desktop-only, BLOCKED in WS channels since board data
is local SQLite). sourceRef.kind: 'board' provenance flows through unchanged.
Invariants (grounded coverage honesty, never-dead-end fallback, cost label + cap,
idempotency, durable provenance, fake-seam fidelity, the E2E lock) are named in the
decks contract. The CLI route for
"turn this session into a deck" is a tracked fast-follow (approval-gated billable
pattern); the channel is manifest-classified feature-scoped-surface like its
siblings until then.
Optional pre-outline interview
The create screen offers "Interview me first" alongside "Generate outline". It runs a short conversational Q&A before the outline so the deck reflects context only you have — audience, the single takeaway, must-includes, proof, tone.
- One turn per question.
decks:interview-turn { prompt, answers[], tone?, audience? }calls Claude with a forcedemit_interviewtool that returns EITHER the next question ({ done: false, question }) or a synthesized brief ({ done: true, brief }). The renderer replays it, appending each{ question, answer }toanswers, untildone. - Bounded + always completes. Capped at
MAX_INTERVIEW_QUESTIONS(5) — at the cap the turn is forced to finish — and any malformed / empty result falls back to a brief woven from the answers so far, so it never loops or dead-ends. - Seeds the outline. On
done(or "Skip to outline"), the store hands the brief straight intodecks:generate-outline { …, brief }; the quick prompt path is unchanged (the interview is opt-in). Each turn is a paid call, deduped on a per-turnclientRequestIdand cost-labelleddecks-interviewunder the same daily cap; theAMC_DECKS_AI_FAKEseam covers it too.
The layout registry — single source of truth
src/shared/decks/layout-registry.ts defines the finite, named layout library
(DECK_LAYOUTS). The generator emits one of these names per card, and the
renderer maps each name to a React component. The Zod layout enum, the
generator's allowed list, and the renderer's name→component map all derive from
this one array — two guard tests keep them in lockstep: layout-registry.test.ts
(the registry shape) and deck-layout-registry-completeness.test.ts (every name
has a renderer component and vice-versa; runs in the always-on guard lane).
Layouts (13): title, section, text, text-image-split, bullets,
two-column, stat (big-number), full-bleed-image, gallery (image grid),
icon-grid, timeline, comparison, quote. Each carries an intentAffinity
(which content intents it suits) that biases the generator's choice. Adding a
layout is one registry entry + a component in both viewers (the parity guard
fails until they match) — the shared slide engine (below) means the static PDF
picks it up for free.
A gallery card holds at most DECK_GALLERY_CELLS (4) images. That constant
(deck-visuals.ts) is the single source of
the cap, and all three consumers read it: the card-generation prompt QUOTES it to
the model, the in-app preview sizes its tiles from deckGalleryArts, and the
published viewer + PDF derive their cell count from it. It has to reach the model,
because a gallery handed more images than the cap renders the first 4 and silently
drops the rest — so an untold generator writes image prompts you pay for and nobody
can ever see (real deck 912f2b95 emitted 7). The cap governs the COUNT only: the 2×2
grid is a deliberate visual constant in both viewers, so raising it is not a one-line
change. Locked by deck-gallery-cap.test.ts in the always-on guard lane
(gallery-cell-cap in the decks contract).
Publish to the web (DECK_PUBLISH)
A Share button in the deck editor publishes the deck as one self-contained web page anyone can open on any device. It rides the existing Share Artifacts pipeline, so a published deck also appears in the Shares panel (revoke / expire / manage like any other share) and re-sharing an unchanged deck returns the same URL (content-hash dedup — no accidental duplicates).
- One canonical viewer + one shared engine.
src/main/services/decks/deck-render.tsis the Main-process twin of the in-app React viewer: both render the same 13-layout registry and read the same shared theme palette (src/shared/decks/deck-themes.ts, promoted toshared/so the two can't drift). The layout rendering itself lives in one pure slide engine string (SLIDE_ENGINE_JS, noDECK/document) that the browser viewer embeds AND the static PDF renderer evaluates server-side in a Nodevm— so the interactive deck, the published deck, and the PDF all draw slides from ONE implementation. The samedeck-layout-registry-completeness.test.tsguard locks both viewers to the registry. Output is fully self-contained (no external fonts/scripts/network) so it renders identically inside the sandboxed, opaque-origin Shares/runiframe and offline alike — 7 themes, scroll/arrow/swipe nav, tasteful entrance motion (reduced-motion safe), a fullscreen present mode, and a single-screen presenter overlay (below) baked in. - Security. Every card value is HTML-escaped at render time; the embedded deck
JSON is
<-escaped so a card containing</script>can't break out of the inline<script>; Present is gated ondocument.fullscreenEnabled(hidden inside the sandbox where it can't work). - Handler.
DECK_PUBLISH { id }renders outside the publishtry(a render bug is a real fault, not a masked publish failure), then callsshareArtifactService.publishArtifact({ pastedContent: html, fullBleed: true, sourceType: 'deck-publish' }). Refuses a 0-card deck and humanizes firebase-quota / upload errors; a fresh publish mirrors to Firestore + emitsSHARE_UPDATED. The renderer store guards a re-entrant click with apublishingflag (the Shares dedup makes a duplicate idempotent regardless).
Real images + generative art
Image blocks now produce pixels, not an empty placeholder:
- Real photos. When a block carries a valid
http(s)url, both viewers emit a real<img>(object-cover, lazy,alt-labelled). The URL is escaped via the viewer'sesc()so a stray quote can't break out of the attribute, and anonerrorremoves a broken image so the art beneath shows through. - Generative art (the always-present base).
src/shared/decks/deck-visuals.tsis a deterministic, seeded mesh-gradient generator —deckArtBackground(seed)returns a plain CSSbackgroundvalue; the seed comes from the card's content (deckArtSeed). It is theme-independent on purpose (real media doesn't recolour when you switch deck theme) and pure (noDate/Math.random), so the published render stays byte-deterministic. Used behind image / cover / section slides, always under a scrim that keeps overlaid text legible — a deck looks designed even with zero photos. Thegallerylayout seeds one art per tile viadeckGalleryArts(seed)(shared by both viewers + the PDF so they can't drift).
Image generation (a paid image model) is still deferred — this wave renders real URLs + procedural art; wiring an image provider is a follow-up.
Related
This page is split across two parts: part 2 covers what each card is told when it is generated, the theme colour, presenter mode and speaker notes, PDF / HTML / PowerPoint export, and the data model, IPC surface and key files behind it all. The virtual-project row itself behaves like any other sidebar entry, which is covered on the projects sidebar page, and a published deck is managed with everything else you have shared — see share artifacts.
Last verified 2026-10-06