Overview
I built an app that turns natural language into Marp slides through a chat interface.
- Live: marp-ai-app.vercel.app
- Repo: yama3133/marp-ai-app (Public)
You can ask "Make 8 slides on AI agent use cases" and the app generates the deck. Then keep chatting: "Make slide 3 more concise" and it edits in place. Exports to PDF and editable PPTX. There is also a "My Themes" feature that lets you paste any Marp CSS, save it to localStorage, and switch on the fly.
This post is a tour of the architecture and the rough edges I hit.
Architecture
Browser
↓
Next.js 16 (Vercel) ─ /api/generate → Amazon Bedrock (Sonnet 4.6, us-east-1)
└ /api/export → Lambda (arm64, ap-northeast-1)
├ marp-cli + Chromium → PDF
└ marp-cli + LibreOffice → editable PPTX
└ S3 (1-day lifecycle) → presigned URL
Auth: Vercel OIDC Federation → IAM Role (keyless, no access keys)
| Role | Tech |
|---|---|
| Frontend / API | Next.js 16 (App Router, Route Handlers) |
| Hosting | Vercel |
| LLM | Amazon Bedrock Converse API (us.anthropic.claude-sonnet-4-6, us-east-1) |
| Export | AWS Lambda (container, arm64, ap-northeast-1) |
| File delivery | Amazon S3 + presigned URLs (1-day lifecycle) |
| Auth | Vercel OIDC Federation → IAM Role (keyless) |
| Preview | marp-core (rendered in the browser) |
1. Multi-turn chat with Bedrock Converse
I wanted a plain chat experience — user message, assistant reply, follow-up edits — so I pass the conversation directly to the Bedrock Converse API's messages[].
Message shape
type ChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
markdown?: string;
};
Each assistant message holds both content (the short conversational reply) and markdown (the Marp deck). When sending to the API, I concatenate them into a single message:
function buildApiMessages(messages: ChatMessage[]): ApiMessage[] {
return messages.map((m) => ({
role: m.role,
content:
m.role === "assistant" && m.markdown
? `${m.content}\n\n${m.markdown}`.trim()
: m.content,
}));
}
System prompt: reply first, then Markdown
I want both a chat reply and a Marp deck back, so the system prompt enforces the format:
- Start with a 1-2 sentence natural reply.
- If generating or editing slides, follow the reply (no blank line) with Marp Markdown.
- Never wrap the Markdown in code fences (```
).
- On edit requests, re-output the entire deck (no diffs, no patches).
The server splits on the first ---\nmarp: occurrence:
ts
function parseResponse(text: string): { message: string; markdown: string } {
const trimmed = text.trim();
let idx = trimmed.search(/(?:^|\n)---\s*\n\s*marp:/);
if (idx === -1) idx = trimmed.search(/(?:^|\n)---\s*\n/);
if (idx === -1) return { message: trimmed, markdown: "" };
const splitAt = idx === 0 ? 0 : idx + 1;
return {
message: trimmed.slice(0, splitAt).trim(),
markdown: trimmed.slice(splitAt).trim(),
};
}
Full-deck re-outputs are easier to keep coherent than diffs (frontmatter, page breaks, closing slide all stay consistent). Since the chat history is already in Bedrock's context, vague follow-ups like "shorter please" still work.
2. Making preview and export look the same
Marp themes are CSS. The challenge:
- Preview:
marp-corerunning in the browser - Export:
marp-clirunning in Lambda
If the theme CSS or font override drifts between the two, you get the classic "preview looks fine but the exported file looks different" bug.
One source of truth for theme CSS
src/lib/themes.ts defines six custom theme CSS strings. Both sides consume them:
ts
// Preview side (marp-core)
for (const t of CUSTOM_THEMES) {
if (t.css) marp.themeSet.add(t.css);
}
// Export side (Lambda: marp-cli)
if (themeCss) {
const themePath = path.join(work, "theme.css");
await writeFile(themePath, themeCss, "utf8");
args.push("--theme-set", themePath);
}
Font override via a <style> block in the Markdown
Marp treats <style> tags inside the Markdown as deck-wide CSS. Inject the same override into both marp-core and marp-cli:
ts
export function fontStyleBlock(font: FontId): string {
const stack = getFontStack(font);
if (!stack) return "";
return `<style>
section,
section :is(h1, h2, h3, h4, h5, h6, p, li, blockquote, th, td, a, strong, em) {
font-family: ${stack} !important;
}
</style>`;
}
3. User-defined themes (localStorage)
I wanted to let users paste any Marp CSS and use it as a theme. The trick is making sure the /* @theme name */ header is always present — if the user forgot, generate one from the label and prepend it:
ts
export function ensureThemeHeader(
css: string,
fallbackLabel: string,
): { name: string; css: string } {
const existing = parseThemeName(css);
if (existing) return { name: existing, css };
const name = slugifyThemeName(fallbackLabel);
return { name, css: `/* @theme ${name} */\n${css}` };
}
Validate against builtin names → save to localStorage → list under a "My Themes" optgroup → on export, send the CSS via userThemeCss so Lambda's existing --theme-set path picks it up. No Lambda changes required.
4. Lambda container: Chromium + LibreOffice in one box
One Lambda handles both formats:
-
marp-cli+ Chromium → PDF (Marp's default path) -
marp-cli+ LibreOffice → editable PPTX (--pptx --pptx-editable)
Three Lambda-specific gotchas worth flagging.
Chromium dies with Connection closed
Lambda has a read-only filesystem, tiny /dev/shm, and forbids sandboxing — Chromium's zygote/multi-process model falls over. Marp doesn't expose low-level browser args, so route through a wrapper script via --browser-path:
dockerfile
RUN printf '#!/bin/sh\nexec /usr/bin/chromium \
--no-sandbox --disable-dev-shm-usage --disable-gpu \
--disable-software-rasterizer --single-process --no-zygote "$@"\n' \
> /usr/local/bin/chromium-marp \
&& chmod +x /usr/local/bin/chromium-marp
ENV CHROME_PATH=/usr/local/bin/chromium-marp
marp-cli hangs waiting on stdin
In Lambda's child_process.spawn, stdin is an open pipe by default, and marp blocks waiting for input. Always pass --no-stdin:
js
const args = ["--no-stdin", "--allow-local-files"];
M PLUS Rounded 1c font family name mismatch
The TTF's internal family name is Rounded Mplus 1c, so listing only 'M PLUS Rounded 1c' in CSS won't match. List both:
css
font-family: 'M PLUS Rounded 1c','Rounded Mplus 1c',sans-serif;
5. Multi-stage build: 3.18 GB → 2.39 GB
My first Dockerfile was a single stage at 3.18 GB. The culprit: the toolchain needed for aws-lambda-ric's native build (g++ / cmake / automake / python3 / libcurl4-openssl-dev) was still in the runtime image.
Split into three stages — runtime dropped to 2.39 GB (-790 MB, -25%):
dockerfile
# 1. Font fetcher
FROM debian:bookworm-slim AS fonts
RUN apt-get install -y --no-install-recommends curl ca-certificates
COPY install-fonts.sh /tmp/
RUN sh /tmp/install-fonts.sh
# 2. node_modules builder (aws-lambda-ric native build)
FROM node:22-bookworm-slim AS builder
RUN apt-get install -y --no-install-recommends \
g++ make cmake autoconf automake libtool pkg-config python3 \
libcurl4-openssl-dev ca-certificates
COPY package.json ./
RUN npm install --omit=dev && npm cache clean --force
# 3. Runtime
FROM node:22-bookworm-slim AS runtime
RUN apt-get install -y --no-install-recommends \
chromium libreoffice-impress fonts-noto-cjk fonts-noto-color-emoji fontconfig
COPY --from=fonts /usr/share/fonts/truetype/marpfonts /usr/share/fonts/truetype/marpfonts
RUN fc-cache -f /usr/share/fonts/truetype/marpfonts
COPY --from=builder /build/node_modules ./node_modules
Key points:
-
fontsstage usescurlto grab 7 font families — runtime never seescurl -
builderstage owns the toolchain; onlynode_modulesis copied to runtime - Runtime only carries
chromium + libreoffice-impress + fonts-noto-cjk + fontconfig
6. Keyless AWS from Vercel via OIDC Federation
I didn't want to store AWS access keys in Vercel env vars. Vercel's OIDC Federation lets you exchange a short-lived token for AWS credentials.
The IAM trust policy locks scope and project:
json
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::761018866498:oidc-provider/oidc.vercel.com/yuuki-yamashitas-projects"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.vercel.com/yuuki-yamashitas-projects:aud":
"https://vercel.com/yuuki-yamashitas-projects",
"oidc.vercel.com/yuuki-yamashitas-projects:sub":
"owner:yuuki-yamashitas-projects:project:marp-ai-app:environment:production"
}
}
}
On the Next.js side, @vercel/functions/oidc does the heavy lifting:
ts
import { awsCredentialsProvider } from "@vercel/functions/oidc";
export function awsCredentials() {
if (process.env.VERCEL) {
return awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN!,
});
}
return undefined; // local: default AWS credential chain
}
Locally I fall back to ~/.aws/credentials via the default chain.
Wrap-up
- Multi-turn chat editing falls out of Bedrock Converse
messages[]if you let the model re-output the full deck on every turn - Match preview and export by sharing theme CSS and injecting font overrides through a
<style>block in the Markdown - For Chromium + LibreOffice in one Lambda:
--single-process --no-zygote --no-sandboxand--no-stdinare non-negotiable - Multi-stage build saved 790 MB
- Vercel → AWS goes through OIDC Federation, no keys involved
Repo: yama3133/marp-ai-app
Live: marp-ai-app.vercel.app
Feedback welcome.













