# AI Generated Frontmatter (Obsidian Plugin)
An Obsidian plugin that automatically generates structured YAML frontmatter for notes using Google's Gemini AI. The plugin analyzes note content and creates intelligent metadata including note type, tags, entities, relationships, and temporal information.
Purpose and Philosophy
The Problem
Obsidian users often struggle with consistent note organization. While frontmatter (YAML metadata at the top of markdown files) enables powerful features like Dataview queries, property views, and relationship tracking, manually adding metadata is tedious and inconsistent.
The Solution
This plugin leverages AI to automatically generate meaningful frontmatter by:
- Analyzing note content to understand its context and type
- Extracting entities like people, projects, and concepts using Obsidian conventions
- Detecting relationships between notes
- Applying consistent taxonomy based on vault-specific terminology
Core Philosophy
The plugin follows these principles:
- Extract, Don't Invent — Only include properties directly supported by content
- Taxonomy First — Use existing vault terminology when detectable
- Minimal Viable Metadata — Maximum 8 properties unless critical
- Obsidian-Native Formats — Support Dataview and Relationships plugins
Architecture
Features
1. AI-Powered Generation
Uses Google Gemini API to analyze note content and generate appropriate frontmatter:
interface GeneratedFrontmatter {
type: 'meeting' | 'research' | 'person' | 'project' |
'reference' | 'idea' | 'task' | 'journal' | 'note';
created: string; // ISO 8601
tags: string[];
entities: string[];
related: string[][];
event?: string;
}Example Output:
---
type: research
created: 2025-05-18
tags: [ai, machine_learning, nlp]
entities: [Google, OpenAI, Anthropic]
related: [["AI Research Overview"], ["LLM Comparison"]]
---2. Multiple Processing Modes
#### Manual Mode (Default) Trigger generation via command palette:
- Open command palette (Ctrl/Cmd + P)
- Search "Generate AI Frontmatter"
- Processes currently active note
#### Automatic Mode Optionally process on:
- New note creation — When a new .md file is created
- Note modification — When content changes (with debounce)
3. Folder Filtering
Control which notes get processed:
// Settings
includedFolders: string; // Only process these folders
excludedFolders: string; // Skip these folders (overrides include)Included: 01-Projects, 02-Areas/People
Excluded: 00-Templates, 03-Resources/Attachments4. Customizable AI Prompt
The default prompt instructs the AI to:
- Identify note type from: meeting, research, person, project, reference, idea, task, journal, note
- Extract entities:
- People: @JohnDoe (titlecase format)
- Projects: #ProjectName (PascalCase)
- Concepts: #snake_case
- Detect temporal context: created date, event date (if explicitly mentioned)
- Determine relationships: related notes with double-bracket format
5. Content Management
contentLimit: number; // Max characters sent to API (default: 4000)
skipNotesWithExistingFrontmatter: boolean; // Skip if frontmatter exists6. Performance Controls
debounceTime: number; // Wait time after last keystroke (default: 5000ms)Prevents excessive API calls while typing by debouncing the modify event.
Settings Configuration
| Setting | Default | Description |
|---|---|---|
| Gemini API Key | (none) | Google Gemini API key |
| Model | gemini-1.5-flash-latest | AI model to use |
| Temperature | 0.3 | AI creativity level (0-1) |
| Auto-process new notes | false | Process on creation |
| Auto-process modified notes | false | Process on modification |
| Debounce delay | 5000ms | Wait after typing stops |
| Skip existing frontmatter | true | Don't overwrite existing |
| Included folders | (all) | Process only these |
| Excluded folders | (none) | Skip these folders |
| Content limit | 4000 | Max chars sent to API |
| Custom prompt | (default) | Modify AI instructions |
Implementation Details
Plugin Structure
obsidian-ai-frontmatter/
├── main.ts # Main plugin code (483 lines)
├── main.js # Compiled JavaScript
├── manifest.json # Plugin manifest
├── styles.css # Plugin styles
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
└── rollup.config.js # Build configKey Classes
#### AIGeneratedFrontmatter (Main Plugin)
export default class AIGeneratedFrontmatter extends Plugin {
settings: AIGeneratedFrontmatterSettings;
private isProcessing: Set<string> = new Set();
private debouncedProcessFile: (file: TFile) => void;
async onload() {
// Register command
// Register vault events
// Load settings
}
async processFile(file: TFile, force: boolean = false) {
// Main processing logic
}
private shouldProcess(file: TFile): boolean {
// Folder filtering logic
}
}#### AISettingsTab (Settings UI)
class AISettingsTab extends PluginSettingTab {
display(): void {
// Creates all settings controls
}
}Gemini API Integration
async function queryGemini(content: string, settings: AIGeneratedFrontmatterSettings): Promise<string> {
const truncatedContent = content.substring(0, settings.contentLimit);
const prompt = settings.customPrompt.replace('{CONTENT}', truncatedContent);
const response = await requestUrl({
url: `https://generativelanguage.googleapis.com/v1beta/models/${settings.model}:generateContent?key=${settings.apiKey}`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: settings.temperature,
response_mime_type: "text/plain",
}
})
});
return response.json.candidates[0].content.parts[0].text.trim();
}YAML Parsing
private cleanYamlResponse(response: string): string | null {
// Try to extract YAML between --- markers
const yamlRegex = /---\s*([\s\S]+?)\s*---/;
const match = response.match(yamlRegex);
if (match && match[1]) {
return `---\n${match[1].trim()}\n---`;
}
// Fallback: if response contains any key:value pairs
if (response.includes(':')) {
return `---\n${response.trim()}\n---`;
}
return null;
}Installation
From Release
- Copy
main.js,manifest.json, andstyles.cssto vault's plugin folder:
vault/.obsidian/plugins/obsidian-ai-frontmatter/- Enable plugin in Obsidian settings
From Source
# Clone or download source
cd obsidian-ai-frontmatter
# Install dependencies
npm install
# Build
npm run build
# Copy output to vaultUsage
First-Time Setup
- Go to Settings → Plugins → AI Generated Frontmatter
- Enter your Gemini API key
- Configure optional settings
Generate Frontmatter Manually
- Open the note
- Press Ctrl/Cmd + P
- Search "Generate AI Frontmatter"
- Press Enter
Enable Auto-Processing
- Open plugin settings
- Toggle "Auto-process new notes" or "Auto-process modified notes"
- Configure folder filters if needed
API Key Setup
- Go to Google AI Studio
- Create a new API key
- Copy to plugin settings
Note: The plugin stores your API key locally in the vault's plugin data. It is never sent anywhere except directly to Google's Gemini API.
Default Prompt Engineering
The default prompt is carefully crafted to produce Obsidian-friendly metadata:
## Note Content
{CONTENT}The AI is instructed to:
- Return ONLY valid YAML between --- markers
- Use ISO 8601 dates
- Use array format for tags
- Include required properties: type, created
- Avoid forbidden properties: content, body, file_path
- Extract @People, #Projects, and #concepts as entities
Use Cases
- Bulk Organization — Run on existing vault to add metadata to unorganized notes
- Daily Notes — Automatically tag and categorize daily journal entries
- Research — Extract entities and relationships from research notes
- Project Tracking — Identify project notes and link related tasks
- Meeting Notes — Tag meetings and extract attendees
Limitations
- API Cost: Uses Google Gemini API (free tier available)
- API Rate Limits: Subject to Google's rate limits
- Content Length: Limited by contentLimit setting (default 4000 chars)
- Language: Best results with English content
- Privacy: Note content sent to Google for processing
Future Enhancements
Potential improvements:
- Support for other AI providers (OpenAI, Anthropic)
- Bulk processing for entire folders
- Custom property templates
- Dataview field support
- Bi-directional link detection
- Template variable support
Tech Stack Summary
| Component | Technology |
|---|---|
| Language | TypeScript 4.7.4 |
| Framework | Obsidian API |
| AI Provider | Google Gemini API |
| Build Tool | esbuild |
| Type Checking | TypeScript (noEmit) |
| Package Manager | npm |
Portfolio Context
This plugin demonstrates:
- Obsidian Plugin Development — Full plugin architecture with settings, commands, events
- API Integration — External AI API integration with error handling
- TypeScript — Type-safe development with Obsidian types
- User Experience — Configuration UI with debouncing and folder filtering
- AI Prompt Engineering — Carefully crafted prompts for structured output
Detailed Implementation Analysis
Event Registration System
The plugin registers multiple event handlers for different triggers:
this.registerEvent(
this.app.vault.on('create', (file) => {
if (this.settings.autoProcessNewNotes && file instanceof TFile) {
setTimeout(() => this.processFile(file), 500);
}
})
);
this.registerEvent(
this.app.vault.on('modify', (file) => {
if (this.settings.autoProcessModifiedNotes && file instanceof TFile) {
this.debouncedProcessFile(file);
}
})
);Why setTimeout for create events?
- When a note is created, it may not have immediately readable content
- The 500ms delay ensures the file is fully written before processing
Why debounce for modify events?
- Users type continuously while editing
- Each keystroke triggers a modify event
- Without debouncing, we'd make dozens of API calls per note edit
- The debounce function waits for the user to stop typing before processing
Processing Pipeline
The main processFile method implements a complete pipeline:
async processFile(file: TFile, force: boolean = false) {
// Step 1: Check if we should process this file
if (!this.shouldProcess(file) || this.isProcessing.has(file.path)) {
return;
}
// Step 2: Mark file as being processed (prevent duplicates)
this.isProcessing.add(file.path);
try {
// Step 3: Read file content
const fileContent = await this.app.vault.cachedRead(file);
// Step 4: Check for existing frontmatter
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
// Step 5: Skip if frontmatter exists (unless force is true)
if (!force && this.settings.skipNotesWithExistingFrontmatter && frontmatter) {
console.log(`Skipping '${file.path}' as it already has frontmatter.`);
return;
}
// Step 6: Extract content to analyze (strip existing frontmatter)
const contentToAnalyze = frontmatter
? fileContent.substring(frontmatter.position.end.offset)
: fileContent;
// Step 7: Validate we have content to analyze
if (!contentToAnalyze.trim()) {
new Notice('No content to analyze.');
return;
}
// Step 8: Show processing notification
const notice = new Notice('Generating AI frontmatter...', 0);
try {
// Step 9: Call Gemini API
const generatedYaml = await queryGemini(contentToAnalyze, this.settings);
// Step 10: Clean and validate the response
const cleanedYaml = this.cleanYamlResponse(generatedYaml);
if (!cleanedYaml) {
new Notice('AI returned empty or invalid frontmatter.');
return;
}
// Step 11: Insert frontmatter into note
await this.app.vault.process(file, (data) => {
const existingFm = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (existingFm) {
// Replace existing frontmatter
return cleanedYaml + '\n' + data.substring(existingFm.position.end.offset).trim();
} else {
// Add new frontmatter at top
return cleanedYaml + '\n\n' + data;
}
});
notice.setMessage('AI frontmatter generated successfully!');
} catch (error) {
console.error(error);
new Notice(`Error generating frontmatter: ${error.message}`, 8000);
} finally {
notice.hide();
}
} catch (error) {
console.error(`Error processing file ${file.path}:`, error);
new Notice(`Failed to read or process file: ${file.path}`);
} finally {
// Step 12: Clean up processing state
this.isProcessing.delete(file.path);
}
}File Filtering Logic
The shouldProcess method implements folder-based filtering:
private shouldProcess(file: TFile): boolean {
// Basic validation
if (!file || !(file instanceof TFile) || file.extension !== 'md') {
return false;
}
const path = file.path.toLowerCase();
// Parse included folders
const included = this.settings.includedFolders
.split(/[\n,]/)
.map(f => f.trim().toLowerCase())
.filter(f => f.length > 0);
// Parse excluded folders
const excluded = this.settings.excludedFolders
.split(/[\n,]/)
.map(f => f.trim().toLowerCase())
.filter(f => f.length > 0);
// Excluded folders take priority
if (excluded.length > 0 && excluded.some(folder => path.startsWith(folder))) {
return false;
}
// If included folders are specified, must match one
if (included.length > 0 && !included.some(folder => path.startsWith(folder))) {
return false;
}
return true;
}Error Handling
The plugin implements comprehensive error handling:
async function queryGemini(content: string, settings: AIGeneratedFrontmatterSettings): Promise<string> {
if (!settings.apiKey) {
throw new Error("Gemini API key is not configured.");
}
try {
const response = await requestUrl({ /* ... */ });
const responseData = response.json;
if (responseData?.candidates?.[0]?.content?.parts?.[0]?.text) {
return responseData.candidates[0].content.parts[0].text.trim();
} else {
throw new Error("Invalid response structure from Gemini API");
}
} catch (error) {
// Specific error handling for common issues
if (error.message.includes("400")) {
throw new Error("API Error: Bad request. Check your API key and model name.");
}
if (error.message.includes("429")) {
throw new Error("API Error: Rate limit exceeded. Please wait and try again.");
}
throw new Error(`Failed to query Gemini API. ${error.message}`);
}
}Concurrent Processing Prevention
The plugin prevents processing the same file twice simultaneously:
private isProcessing: Set<string> = new Set();
// In processFile:
if (this.isProcessing.has(file.path)) {
return; // Already processing this file
}
this.isProcessing.add(file.path);
// ... processing ...
this.isProcessing.delete(file.path); // In finally blockThis prevents race conditions when:
- User triggers manual processing while auto-processing
- Multiple rapid modification events fire
- User opens same file in multiple panes
Command Registration
The plugin registers a command available in the command palette:
this.addCommand({
id: 'generate-ai-frontmatter',
name: 'Generate AI Frontmatter',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (file) {
if (!checking) {
// Force process for command (ignores existing frontmatter)
this.processFile(file, true);
}
return true;
}
return false;
}
});The command uses force: true to override the "skip existing frontmatter" setting when users explicitly trigger generation.
Settings Persistence
Settings are stored in the vault's plugin data:
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
// Recreate debounced function with new settings
this.debouncedProcessFile = debounce(
(file: TFile) => this.processFile(file),
this.settings.debounceTime,
true
);
}This ensures:
- Settings persist across Obsidian restarts
- API key is stored securely (locally only)
- Debounce time updates apply immediately
Performance Considerations
Content Limiting
const truncatedContent = content.substring(0, settings.contentLimit);The default 4000 character limit:
- Reduces API costs
- Keeps processing fast
- Provides enough context for good metadata
- Can be adjusted in settings for longer notes
Cached Reading
const fileContent = await this.app.vault.cachedRead(file);Uses cached read instead of raw read for better performance on large vaults.
Comparison with Alternatives
| Feature | AI Generated Frontmatter | Manual | Other Plugins |
|---|---|---|---|
| Automation | AI-powered | Manual entry | Template-based |
| Intelligence | Understands content | N/A | Fixed rules |
| Customization | Custom prompts | Full control | Template variables |
| Learning | Adapts to vault | N/A | None |
| Cost | API calls (free tier) | Free | Free |
Security Considerations
- API Key Storage: Stored in local plugin data, never transmitted except to Google
- Content Privacy: Note content sent to Google for processing (consider for sensitive notes)
- No Cloud Sync: All processing happens locally in Obsidian
- Network Requests: Only to Google's Gemini API, no third-party servers
Troubleshooting Guide
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| "API key not configured" | No API key entered | Add key in plugin settings |
| "Invalid response" | Wrong API key or model | Verify API key, check model name |
| "Rate limit exceeded" | Too many requests | Wait, reduce auto-processing |
| "No content to analyze" | Empty note | Add content to note first |
| "Skipping - already has frontmatter" | Existing frontmatter | Use command to override |
Debug Mode
Check Obsidian developer console (Ctrl+Shift+I) for detailed logs:
console.log(`Processing '${file.path}'`);
console.error("Gemini API Error:", error);Extending the Plugin
Adding Custom Properties
Modify the prompt to extract additional properties:
## Content Analysis
- Identify PRIMARY NOTE TYPE: [meeting|research|person|project|reference|idea|task|journal|note]
- Extract entities:
People: @JohnDoe (titlecase)
Projects: #ProjectName (PascalCase)
- Detect temporal context:
created: <current ISO date>
event: <date> (only if explicitly mentioned)
- Add priority if task:
priority: [high, medium, low] (only for type:task)Adding New AI Providers
To add OpenAI support:
// Add to settings
apiProvider: 'gemini' | 'openai',
openaiModel: string,
// Modify query function
async function queryAI(content: string, settings: AIGeneratedFrontmatterSettings): Promise<string> {
if (settings.apiProvider === 'gemini') {
return await queryGemini(content, settings);
} else if (settings.apiProvider === 'openai') {
return await queryOpenAI(content, settings);
}
}Development Workflow
# Install dependencies
npm install
# Development mode (watch for changes)
npm run dev
# Production build
npm run build
# Copy to vault
cp main.js manifest.json styles.css ~/.obsidian/plugins/obsidian-ai-frontmatter/Version History
| Version | Date | Changes |
|---|---|---|
| 1.0.0 | July 2025 | Initial release with Gemini API |
Conclusion
This plugin represents a practical application of AI for personal knowledge management. By automatically generating structured frontmatter, it helps users maintain consistent metadata across their Obsidian vault without manual effort. The plugin demonstrates modern plugin development patterns including event-driven architecture, configurable processing, and external API integration.
Architecture Feedback
Spotted a potential optimization or antipattern? Let me know.