feat: extract presentation generator into executable code with builtin tool

Move the presentation implementation out of the skill string into real
TypeScript files (types.ts, presentation-generator.tsx) and add a
generatePresentation builtin tool so the agent calls it directly instead
of writing code. Rewrite the skill to guidance-only with content limits,
preference gathering, and JSON examples for each slide type.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-01-30 13:30:06 +05:30
parent a3e681a7c4
commit e6c6571b07
9 changed files with 1392 additions and 1 deletions

View file

@ -26,6 +26,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
## Memory That Compounds

View file

@ -0,0 +1,446 @@
import React from 'react';
import {
Document,
Page,
Text,
View,
Image,
StyleSheet,
renderToFile,
} from '@react-pdf/renderer';
import type { Slide, Theme, PresentationData, TitleSlide, ContentSlide, SectionSlide, StatsSlide, TwoColumnSlide, QuoteSlide, ImageSlide, TeamSlide, CTASlide } from './types.js';
const defaultTheme: Theme = {
primaryColor: '#6366f1',
secondaryColor: '#8b5cf6',
accentColor: '#f59e0b',
textColor: '#1f2937',
textLight: '#6b7280',
background: '#ffffff',
backgroundAlt: '#f9fafb',
fontFamily: 'Helvetica',
};
const SLIDE_WIDTH = 1280;
const SLIDE_HEIGHT = 720;
const createStyles = (theme: Theme) =>
StyleSheet.create({
slide: {
width: SLIDE_WIDTH,
height: SLIDE_HEIGHT,
padding: 60,
backgroundColor: theme.background,
position: 'relative',
},
slideAlt: {
backgroundColor: theme.backgroundAlt,
},
slideGradient: {
backgroundColor: theme.primaryColor,
},
pageNumber: {
position: 'absolute',
bottom: 30,
right: 40,
fontSize: 14,
color: theme.textLight,
},
slideTitle: {
fontSize: 42,
fontWeight: 'bold',
color: theme.textColor,
marginBottom: 30,
},
slideBody: {
fontSize: 24,
color: theme.textColor,
lineHeight: 1.6,
},
titleSlide: {
justifyContent: 'center' as const,
alignItems: 'center' as const,
},
mainTitle: {
fontSize: 64,
fontWeight: 'bold',
color: '#ffffff',
textAlign: 'center' as const,
marginBottom: 20,
},
mainSubtitle: {
fontSize: 28,
color: 'rgba(255, 255, 255, 0.9)',
textAlign: 'center' as const,
marginBottom: 30,
},
presenter: {
fontSize: 20,
color: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center' as const,
},
titleDecoration: {
position: 'absolute' as const,
bottom: 0,
left: 0,
right: 0,
height: 8,
backgroundColor: theme.accentColor,
},
sectionNumber: {
fontSize: 80,
fontWeight: 'bold',
color: theme.primaryColor,
opacity: 0.2,
marginBottom: -20,
},
sectionTitle: {
fontSize: 56,
fontWeight: 'bold',
color: theme.textColor,
},
sectionSubtitle: {
fontSize: 24,
color: theme.textLight,
marginTop: 15,
},
contentList: {
marginTop: 10,
},
listItem: {
flexDirection: 'row' as const,
marginBottom: 16,
alignItems: 'flex-start' as const,
},
listBullet: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: theme.primaryColor,
marginRight: 20,
marginTop: 8,
},
listText: {
flex: 1,
fontSize: 24,
color: theme.textColor,
lineHeight: 1.5,
},
columnsContainer: {
flexDirection: 'row' as const,
flex: 1,
gap: 60,
},
column: {
flex: 1,
},
columnTitle: {
fontSize: 24,
fontWeight: 'bold',
color: theme.primaryColor,
marginBottom: 15,
},
statsGrid: {
flexDirection: 'row' as const,
justifyContent: 'space-around' as const,
alignItems: 'center' as const,
flex: 1,
},
statItem: {
alignItems: 'center' as const,
padding: 30,
},
statValue: {
fontSize: 72,
fontWeight: 'bold',
color: theme.primaryColor,
marginBottom: 10,
},
statLabel: {
fontSize: 20,
color: theme.textLight,
textTransform: 'uppercase' as const,
letterSpacing: 1,
},
statsNote: {
textAlign: 'center' as const,
fontSize: 18,
color: theme.textLight,
marginTop: 20,
},
quoteSlide: {
justifyContent: 'center' as const,
alignItems: 'center' as const,
},
quoteText: {
fontSize: 36,
fontStyle: 'italic',
color: theme.textColor,
textAlign: 'center' as const,
maxWidth: 900,
lineHeight: 1.5,
},
quoteAttribution: {
fontSize: 20,
color: theme.textLight,
marginTop: 30,
textAlign: 'center' as const,
},
imageContainer: {
flex: 1,
justifyContent: 'center' as const,
alignItems: 'center' as const,
marginVertical: 20,
},
slideImage: {
maxWidth: '100%',
maxHeight: 450,
objectFit: 'contain' as const,
},
imageCaption: {
textAlign: 'center' as const,
fontSize: 18,
color: theme.textLight,
},
teamGrid: {
flexDirection: 'row' as const,
justifyContent: 'center' as const,
gap: 50,
flex: 1,
alignItems: 'center' as const,
},
teamMember: {
alignItems: 'center' as const,
maxWidth: 200,
},
memberPhotoPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: theme.primaryColor,
marginBottom: 15,
},
memberPhoto: {
width: 120,
height: 120,
borderRadius: 60,
marginBottom: 15,
},
memberName: {
fontSize: 20,
fontWeight: 'bold',
color: theme.textColor,
textAlign: 'center' as const,
},
memberRole: {
fontSize: 16,
color: theme.primaryColor,
marginTop: 5,
textAlign: 'center' as const,
},
memberBio: {
fontSize: 14,
color: theme.textLight,
marginTop: 10,
textAlign: 'center' as const,
lineHeight: 1.4,
},
ctaSlide: {
justifyContent: 'center' as const,
alignItems: 'center' as const,
},
ctaTitle: {
fontSize: 56,
fontWeight: 'bold',
color: '#ffffff',
textAlign: 'center' as const,
marginBottom: 20,
},
ctaSubtitle: {
fontSize: 24,
color: 'rgba(255, 255, 255, 0.9)',
textAlign: 'center' as const,
marginBottom: 40,
},
ctaContact: {
fontSize: 20,
color: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center' as const,
},
});
type Styles = ReturnType<typeof createStyles>;
const TitleSlideComponent: React.FC<{ slide: TitleSlide; styles: Styles }> = ({ slide, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={[styles.slide, styles.slideGradient, styles.titleSlide]}>
<Text style={styles.mainTitle}>{slide.title}</Text>
{slide.subtitle && <Text style={styles.mainSubtitle}>{slide.subtitle}</Text>}
{slide.presenter && <Text style={styles.presenter}>{slide.presenter}</Text>}
<View style={styles.titleDecoration} />
</Page>
);
const SectionSlideComponent: React.FC<{ slide: SectionSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={[styles.slide, styles.slideAlt]}>
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text style={styles.sectionNumber}>{String(pageNum).padStart(2, '0')}</Text>
<Text style={styles.sectionTitle}>{slide.title}</Text>
{slide.subtitle && <Text style={styles.sectionSubtitle}>{slide.subtitle}</Text>}
</View>
<Text style={styles.pageNumber}>{pageNum}</Text>
</Page>
);
const ContentSlideComponent: React.FC<{ slide: ContentSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={styles.slide}>
<Text style={styles.slideTitle}>{slide.title}</Text>
{slide.content && <Text style={styles.slideBody}>{slide.content}</Text>}
{slide.items && (
<View style={styles.contentList}>
{slide.items.map((item, i) => (
<View key={i} style={styles.listItem}>
<View style={styles.listBullet} />
<Text style={styles.listText}>{item}</Text>
</View>
))}
</View>
)}
<Text style={styles.pageNumber}>{pageNum}</Text>
</Page>
);
const TwoColumnSlideComponent: React.FC<{ slide: TwoColumnSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={styles.slide}>
<Text style={styles.slideTitle}>{slide.title}</Text>
<View style={styles.columnsContainer}>
{slide.columns.map((col, i) => (
<View key={i} style={styles.column}>
{col.title && <Text style={styles.columnTitle}>{col.title}</Text>}
{col.content && <Text style={styles.slideBody}>{col.content}</Text>}
{col.items && (
<View style={styles.contentList}>
{col.items.map((item, j) => (
<View key={j} style={styles.listItem}>
<View style={styles.listBullet} />
<Text style={styles.listText}>{item}</Text>
</View>
))}
</View>
)}
</View>
))}
</View>
<Text style={styles.pageNumber}>{pageNum}</Text>
</Page>
);
const StatsSlideComponent: React.FC<{ slide: StatsSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={styles.slide}>
<Text style={styles.slideTitle}>{slide.title}</Text>
<View style={styles.statsGrid}>
{slide.stats.map((stat, i) => (
<View key={i} style={styles.statItem}>
<Text style={styles.statValue}>{stat.value}</Text>
<Text style={styles.statLabel}>{stat.label}</Text>
</View>
))}
</View>
{slide.note && <Text style={styles.statsNote}>{slide.note}</Text>}
<Text style={styles.pageNumber}>{pageNum}</Text>
</Page>
);
const QuoteSlideComponent: React.FC<{ slide: QuoteSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={[styles.slide, styles.slideAlt, styles.quoteSlide]}>
<Text style={styles.quoteText}>"{slide.quote}"</Text>
{slide.attribution && <Text style={styles.quoteAttribution}> {slide.attribution}</Text>}
<Text style={styles.pageNumber}>{pageNum}</Text>
</Page>
);
const ImageSlideComponent: React.FC<{ slide: ImageSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={styles.slide}>
<Text style={styles.slideTitle}>{slide.title}</Text>
<View style={styles.imageContainer}>
<Image src={slide.imagePath} style={styles.slideImage} />
</View>
{slide.caption && <Text style={styles.imageCaption}>{slide.caption}</Text>}
<Text style={styles.pageNumber}>{pageNum}</Text>
</Page>
);
const TeamSlideComponent: React.FC<{ slide: TeamSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={styles.slide}>
<Text style={styles.slideTitle}>{slide.title}</Text>
<View style={styles.teamGrid}>
{slide.members.map((member, i) => (
<View key={i} style={styles.teamMember}>
{member.photoPath ? (
<Image src={member.photoPath} style={styles.memberPhoto} />
) : (
<View style={styles.memberPhotoPlaceholder} />
)}
<Text style={styles.memberName}>{member.name}</Text>
<Text style={styles.memberRole}>{member.role}</Text>
{member.bio && <Text style={styles.memberBio}>{member.bio}</Text>}
</View>
))}
</View>
<Text style={styles.pageNumber}>{pageNum}</Text>
</Page>
);
const CTASlideComponent: React.FC<{ slide: CTASlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => (
<Page size={[SLIDE_WIDTH, SLIDE_HEIGHT]} style={[styles.slide, styles.slideGradient, styles.ctaSlide]}>
<Text style={styles.ctaTitle}>{slide.title}</Text>
{slide.subtitle && <Text style={styles.ctaSubtitle}>{slide.subtitle}</Text>}
{slide.contact && <Text style={styles.ctaContact}>{slide.contact}</Text>}
<Text style={[styles.pageNumber, { color: 'rgba(255,255,255,0.6)' }]}>{pageNum}</Text>
</Page>
);
const renderSlide = (
slide: Slide,
index: number,
styles: Styles
): React.ReactElement => {
const pageNum = index + 1;
switch (slide.type) {
case 'title':
return <TitleSlideComponent key={index} slide={slide} styles={styles} />;
case 'section':
return <SectionSlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
case 'content':
return <ContentSlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
case 'two-column':
return <TwoColumnSlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
case 'stats':
return <StatsSlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
case 'quote':
return <QuoteSlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
case 'image':
return <ImageSlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
case 'team':
return <TeamSlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
case 'cta':
return <CTASlideComponent key={index} slide={slide} pageNum={pageNum} styles={styles} />;
default:
return <ContentSlideComponent key={index} slide={slide as ContentSlide} pageNum={pageNum} styles={styles} />;
}
};
const Presentation: React.FC<PresentationData> = ({ slides, theme }) => {
const mergedTheme = { ...defaultTheme, ...theme };
const styles = createStyles(mergedTheme);
return <Document>{slides.map((slide, i) => renderSlide(slide, i, styles))}</Document>;
};
export async function generatePresentation(
data: PresentationData,
outputPath: string
): Promise<string> {
await renderToFile(<Presentation {...data} />, outputPath);
return outputPath;
}

View file

@ -0,0 +1,367 @@
export const skill = String.raw`
# PDF Presentation Generator Skill
## Overview
This skill enables Rowboat to create stunning PDF presentations from natural language requests. Use the built-in **generatePresentation** tool to render slides to PDF.
## When to Use This Skill
Activate this skill when the user requests:
- Creating presentations, slide decks, or pitch decks
- Making PDF slides for meetings, talks, or pitches
- Generating visual summaries or reports in presentation format
- Keywords: "presentation", "slides", "deck", "pitch deck", "slide deck", "PDF presentation"
## Knowledge Sources
Before creating any presentation, gather context from the user's knowledge base:
~~~
~/.rowboat/knowledge/
company/
about.md # Company description, mission, vision
team.md # Founder bios, team members
metrics.md # KPIs, growth numbers, financials
product.md # Product description, features, roadmap
branding.md # Colors, fonts, logo paths, style guide
fundraising/
previous-rounds.md # Past funding history
investors.md # Current investors, target investors
use-of-funds.md # How funds will be allocated
projections.md # Financial projections
market/
problem.md # Problem statement
solution.md # How product solves it
competitors.md # Competitive landscape
tam-sam-som.md # Market size analysis
traction.md # Customer testimonials, case studies
assets/
logo.png # Company logo
product-screenshots/
team-photos/
~~~
**Important:** Always check for and read relevant files from ~/.rowboat/knowledge/ before generating content. If files don't exist, ask the user for the information and offer to save it for future use.
## Workflow
### Step 1: Understand the Request & Gather Preferences
Before doing anything else, ask the user about their preferences:
1. **Content density**: Should the slides be text-heavy with detailed explanations, or minimal with just key points and big numbers?
2. **Color / theme**: Do they have brand colors or a color preference? (e.g., "use our brand blue #2563eb" or "dark theme" or "keep it default")
3. **Presentation type**: pitch deck, product demo, team intro, investor update, etc.
4. **Audience**: investors, customers, internal team, conference
5. **Tone**: formal, casual, technical, inspirational
6. **Length**: number of slides (default: 10-12 for pitch decks)
Ask these as a concise set of questions in a single message. Use any answers the user already provided in their initial request and only ask about what's missing.
### Step 2: Gather Knowledge
~~~bash
# Check what knowledge exists
ls -la ~/.rowboat/knowledge/ 2>/dev/null || echo "No knowledge directory found"
# Read relevant files based on presentation type
# For a pitch deck, prioritize:
cat ~/.rowboat/knowledge/company/about.md 2>/dev/null
cat ~/.rowboat/knowledge/market/problem.md 2>/dev/null
cat ~/.rowboat/knowledge/company/metrics.md 2>/dev/null
cat ~/.rowboat/knowledge/company/branding.md 2>/dev/null
~~~
### Step 3: Present the Outline for Approval
Before generating slides, present a structured outline to the user:
~~~
## Proposed Presentation Outline
**Title:** [Presentation Title]
**Slides:** [N] slides
**Estimated read time:** [X] minutes
### Flow:
1. **Title Slide**
- Company name, tagline, presenter name
2. **Problem**
- [One sentence summary of the problem]
3. **Solution**
- [One sentence summary of your solution]
...
---
Does this look good? I can adjust the outline, then I'll go ahead and generate the PDF for you.
- Add/remove slides
- Reorder sections
- Adjust emphasis on any area
~~~
After the user approves (or after incorporating their feedback), immediately ask: **"I'll generate the PDF now — where should I save it?"** If the user has already indicated a path or preference, skip asking and generate directly.
**IMPORTANT:** Always generate the PDF. Never suggest the user copy content into Keynote, Google Slides, or any other tool. The whole point of this skill is to produce a finished PDF.
### Step 4: Generate the Presentation
Once approved, call the **generatePresentation** tool with the slides JSON and output path. Apply the user's theme/color preferences from Step 1.
## Slide Types Reference
| Type | Description | Required Fields |
|------|-------------|-----------------|
| title | Opening slide with gradient background | title |
| section | Section divider with large number | title |
| content | Standard content slide | title, content or items |
| two-column | Two column layout | title, columns (array of 2) |
| stats | Big numbers display | title, stats (array of {value, label}) |
| quote | Testimonial/quote | quote |
| image | Image with caption | title, imagePath |
| team | Team member grid | title, members (array) |
| cta | Call to action / closing | title |
## Slide Type Details
### title
~~~json
{
"type": "title",
"title": "Company Name",
"subtitle": "Tagline or description",
"presenter": "Name • Context • Date"
}
~~~
### content
~~~json
{
"type": "content",
"title": "Slide Title",
"content": "Optional paragraph text",
"items": ["Bullet point 1", "Bullet point 2", "Bullet point 3"]
}
~~~
### section
~~~json
{
"type": "section",
"title": "Section Title",
"subtitle": "Optional subtitle"
}
~~~
### stats
~~~json
{
"type": "stats",
"title": "Key Metrics",
"stats": [
{ "value": "$5M", "label": "Revenue" },
{ "value": "150%", "label": "YoY Growth" },
{ "value": "10K+", "label": "Users" }
],
"note": "Optional footnote"
}
~~~
### two-column
~~~json
{
"type": "two-column",
"title": "Comparison",
"columns": [
{
"title": "Column A",
"content": "Optional text",
"items": ["Item 1", "Item 2"]
},
{
"title": "Column B",
"content": "Optional text",
"items": ["Item 1", "Item 2"]
}
]
}
~~~
### quote
~~~json
{
"type": "quote",
"quote": "The quote text goes here.",
"attribution": "Person Name, Title"
}
~~~
### image
~~~json
{
"type": "image",
"title": "Product Screenshot",
"imagePath": "/absolute/path/to/image.png",
"caption": "Optional caption"
}
~~~
### team
~~~json
{
"type": "team",
"title": "Our Team",
"members": [
{
"name": "Jane Doe",
"role": "CEO",
"bio": "Optional short bio",
"photoPath": "/absolute/path/to/photo.png"
}
]
}
~~~
### cta
~~~json
{
"type": "cta",
"title": "Let's Build Together",
"subtitle": "email@company.com",
"contact": "website.com • github.com/org"
}
~~~
## Theme Customization
Pass an optional theme object to customize colors:
~~~json
{
"primaryColor": "#2563eb",
"secondaryColor": "#7c3aed",
"accentColor": "#f59e0b",
"textColor": "#1f2937",
"textLight": "#6b7280",
"background": "#ffffff",
"backgroundAlt": "#f9fafb",
"fontFamily": "Helvetica"
}
~~~
All theme fields are optional defaults are used for any omitted fields.
## Example: Calling generatePresentation
~~~json
{
"slides": [
{
"type": "title",
"title": "Acme Corp",
"subtitle": "Revolutionizing Widget Manufacturing",
"presenter": "Jane Doe • Series A • 2025"
},
{
"type": "content",
"title": "The Problem",
"items": [
"Widget production is slow and expensive",
"Legacy systems can't keep up with demand",
"Quality control remains manual"
]
},
{
"type": "stats",
"title": "Traction",
"stats": [
{ "value": "500+", "label": "Customers" },
{ "value": "$2M", "label": "ARR" },
{ "value": "3x", "label": "YoY Growth" }
]
},
{
"type": "cta",
"title": "Let's Talk",
"subtitle": "jane@acme.com",
"contact": "acme.com"
}
],
"theme": {
"primaryColor": "#2563eb"
},
"outputPath": "/Users/user/Desktop/acme_pitch.pdf"
}
~~~
## Pitch Deck Templates
### Series A Pitch Deck (12 slides)
Standard flow for investor presentations:
1. **Title** (type: title) - Company name, tagline, presenter
2. **Problem** (type: content) - What pain point you solve
3. **Solution** (type: content) - Your product/service
4. **Product** (type: image) - Demo/screenshots
5. **Market** (type: stats) - TAM/SAM/SOM
6. **Business Model** (type: content) - How you make money
7. **Traction** (type: stats) - Metrics and growth
8. **Competition** (type: two-column) - Your differentiation
9. **Team** (type: team) - Key team members
10. **Financials** (type: content or stats) - Projections
11. **The Ask** (type: content) - Funding amount and use
12. **Contact** (type: cta) - CTA with contact info
### Product Demo Deck (8 slides)
1. **Title** - Product name and tagline
2. **Problem** - User pain points
3. **Solution** - High-level approach
4. **Features** - Key capabilities (two-column)
5. **Demo** - Screenshots (image)
6. **Pricing** - Plans and pricing
7. **Testimonials** - Customer quotes (quote)
8. **Get Started** - CTA
## Content Limits Per Slide (IMPORTANT)
Each slide is a fixed 1280x720 page. Content that exceeds the available space will be clipped. Follow these limits strictly:
| Slide Type | Max Items / Content |
|------------|-------------------|
| content | 5 bullet points max (keep each bullet to 1 line, ~80 chars). If using paragraph text instead, max ~4 lines. |
| two-column | 4 bullet points per column max. Keep bullets short (~60 chars). |
| stats | 3-4 stats max. Keep labels short (1-2 words). |
| team | 4 members max per slide. Split into multiple team slides if needed. |
| quote | Keep quotes under ~200 characters. |
| image | Caption should be 1 line. |
**If the user's content needs more space**, split it across multiple slides of the same type rather than cramming it into one. For example, if there are 8 bullet points, use two content slides (4 each) with titles like "Key Benefits (1/2)" and "Key Benefits (2/2)".
## Best Practices
1. **Keep slides simple** - One idea per slide
2. **Use stats slides for numbers** - Big, bold metrics
3. **Limit bullet points** - 3-5 max per slide, keep them short
4. **Use two-column for comparisons** - Us vs. them, before/after
5. **End with clear CTA** - What do you want them to do?
6. **Gather knowledge first** - Check ~/.rowboat/knowledge/ before generating
7. **Use absolute paths** for images (PNG, JPG supported)
8. **Never overflow** - If content doesn't fit, split across multiple slides
## Output
The generatePresentation tool produces:
- **PDF file** at the specified outputPath
- **16:9 aspect ratio** (1280x720px per slide)
- **Print-ready** quality
- **Embedded fonts** for portability
`;
export default skill;

View file

@ -0,0 +1,100 @@
export interface SlideBase {
type: string;
title?: string;
subtitle?: string;
content?: string;
}
export interface TitleSlide extends SlideBase {
type: 'title';
title: string;
subtitle?: string;
presenter?: string;
}
export interface ContentSlide extends SlideBase {
type: 'content';
title: string;
content?: string;
items?: string[];
}
export interface SectionSlide extends SlideBase {
type: 'section';
title: string;
subtitle?: string;
}
export interface StatsSlide extends SlideBase {
type: 'stats';
title: string;
stats: Array<{ value: string; label: string }>;
note?: string;
}
export interface TwoColumnSlide extends SlideBase {
type: 'two-column';
title: string;
columns: [
{ title?: string; content?: string; items?: string[] },
{ title?: string; content?: string; items?: string[] }
];
}
export interface QuoteSlide extends SlideBase {
type: 'quote';
quote: string;
attribution?: string;
}
export interface ImageSlide extends SlideBase {
type: 'image';
title: string;
imagePath: string;
caption?: string;
}
export interface TeamSlide extends SlideBase {
type: 'team';
title: string;
members: Array<{
name: string;
role: string;
bio?: string;
photoPath?: string;
}>;
}
export interface CTASlide extends SlideBase {
type: 'cta';
title: string;
subtitle?: string;
contact?: string;
}
export type Slide =
| TitleSlide
| ContentSlide
| SectionSlide
| StatsSlide
| TwoColumnSlide
| QuoteSlide
| ImageSlide
| TeamSlide
| CTASlide;
export interface Theme {
primaryColor: string;
secondaryColor: string;
accentColor: string;
textColor: string;
textLight: string;
background: string;
backgroundAlt: string;
fontFamily: string;
}
export interface PresentationData {
slides: Slide[];
theme?: Partial<Theme>;
}

View file

@ -8,6 +8,7 @@ import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import workflowAuthoringSkill from "./workflow-authoring/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
const CURRENT_FILE = fileURLToPath(import.meta.url);
@ -29,6 +30,13 @@ type ResolvedSkill = {
};
const definitions: SkillDefinition[] = [
{
id: "create-presentations",
title: "Create Presentations",
folder: "create-presentations",
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
content: createPresentationsSkill,
},
{
id: "doc-collab",
title: "Document Collaboration",

View file

@ -12,6 +12,7 @@ import * as workspace from "../../workspace/workspace.js";
import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js";
import type { ToolContext } from "./exec-tool.js";
import { generatePresentation } from "../assistant/skills/create-presentations/presentation-generator.js";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const BuiltinToolsSchema = z.record(z.string(), z.object({
@ -606,6 +607,71 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
generatePresentation: {
description: 'Generate a PDF presentation from slide data. Creates a 16:9 PDF with styled slides.',
inputSchema: z.object({
slides: z.array(z.object({
type: z.enum(['title', 'content', 'section', 'stats', 'two-column', 'quote', 'image', 'team', 'cta']),
title: z.string().optional(),
subtitle: z.string().optional(),
content: z.string().optional(),
presenter: z.string().optional(),
items: z.array(z.string()).optional(),
stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
note: z.string().optional(),
columns: z.array(z.object({
title: z.string().optional(),
content: z.string().optional(),
items: z.array(z.string()).optional(),
})).optional(),
quote: z.string().optional(),
attribution: z.string().optional(),
imagePath: z.string().optional(),
caption: z.string().optional(),
members: z.array(z.object({
name: z.string(),
role: z.string(),
bio: z.string().optional(),
photoPath: z.string().optional(),
})).optional(),
contact: z.string().optional(),
})).describe('Array of slide objects'),
theme: z.object({
primaryColor: z.string().optional(),
secondaryColor: z.string().optional(),
accentColor: z.string().optional(),
textColor: z.string().optional(),
textLight: z.string().optional(),
background: z.string().optional(),
backgroundAlt: z.string().optional(),
fontFamily: z.string().optional(),
}).optional().describe('Optional theme customization'),
outputPath: z.string().describe('Absolute path for the output PDF file'),
}),
execute: async ({ slides, theme, outputPath }: {
slides: Array<Record<string, unknown>>;
theme?: Record<string, string>;
outputPath: string;
}) => {
try {
const result = await generatePresentation(
{ slides: slides as never, theme },
outputPath,
);
return {
success: true,
outputPath: result,
slideCount: slides.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
executeCommand: {
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
inputSchema: z.object({