| name | inbox-processing-expert |
| description | Expert guidance for building and maintaining the Para Obsidian inbox processing system - a security-hardened automation framework for processing PDFs and attachments with AI-powered metadata extraction. Use when building inbox processors, implementing security patterns (TOCTOU, command injection prevention, atomic writes), designing interactive CLIs with suggestion workflows, integrating LLM detection, implementing idempotency with SHA256 registries, or working with the para-obsidian inbox codebase. Covers engine/interface separation, suggestion-based architecture, confidence scoring, error taxonomy, structured logging, and testing patterns. Useful when user mentions inbox automation, PDF processing, document classification, security-hardened file processing, or interactive CLI design. |
| allowed-tools | Read, Grep, Glob |
Inbox Processing Expert
Build security-hardened inbox automation with AI-powered metadata extraction following the Para Obsidian inbox processing framework.
Quick Navigation
- Architecture Overview - Engine/interface separation, suggestion-based design
- Security Patterns - P0 critical protections (TOCTOU, command injection, atomic writes)
- Core Concepts - Suggestions, confidence scoring, idempotency
- Performance Characteristics - Timing, concurrency limits, optimization
- Interactive CLI - Terminal UI, command parsing, user feedback
- Error Handling - 23-error taxonomy across 7 categories
- Testing Strategy - 246 tests, coverage patterns
- Common Questions - FAQ and troubleshooting
- Related Skills - Bun CLI, Bun FS Helpers
Architecture Overview
Engine/Interface Separation
Core principle: Engine logic is UI-agnostic. Same core powers CLI, web app, or API.
const engine = createInboxEngine({ vaultPath: "/path/to/vault" });
const suggestions = await engine.scan();
const updated = await engine.editWithPrompt("abc123", "put in Health area instead");
const results = await engine.execute(["abc123", "def456"]);
const report = engine.generateReport(suggestions);
Benefits:
- UI can be replaced without touching core logic
- Easy to test (engine is pure logic, no console.log or process.exit)
- Multiple interfaces (CLI, web, CI/CD) share same engine
Suggestion-Based Architecture
Never mutate state directly. All operations return suggestions that require human approval.
interface InboxSuggestion {
id: string;
source: string;
processor: "attachments" | "notes" | "images";
confidence: "high" | "medium" | "low";
action: "create-note" | "move" | "rename" | "link" | "skip";
suggestedNoteType?: string;
suggestedTitle?: string;
suggestedDestination?: string;
suggestedArea?: string;
suggestedProject?: string;
extractedFields?: Record<string, unknown>;
suggestedAttachmentName?: string;
attachmentLink?: string;
reason: string;
}
Key insight: Suggestions are immutable. editWithPrompt() returns a NEW suggestion.
Security Patterns
P0 Critical Protections
1. Command Injection Prevention
Always use array args, never string interpolation.
await $`pdftotext ${filePath} -`;
const proc = Bun.spawn(["pdftotext", filePath, "-"]);
Related: See Bun FS Helpers skill for command-injection-safe filesystem operations.
2. TOCTOU (Time-of-Check-Time-of-Use) Mitigation
Check file before AND after operations to detect tampering.
import { stat } from "@side-quest/core/fs";
const preStats = await stat(filePath);
const text = await extractPdfText(filePath, cid);
const postStats = await stat(filePath);
if (postStats.mtimeMs !== preStats.mtimeMs) {
throw createInboxError("EXT_PDF_TOCTOU", { cid, source: filePath });
}
Use case: Prevent file swapping during multi-step operations.
3. Atomic Registry Writes
Write to temp file, then atomically rename.
import { rename } from "@side-quest/core/fs";
await Bun.write(tempPath, JSON.stringify(registry));
await rename(tempPath, registryPath);
Why: Prevents corrupt registry if process crashes mid-write.
4. File Locking
Acquire lock before concurrent operations.
await acquireLock(lockPath);
try {
} finally {
releaseLock(lockPath);
}
Use case: Multiple processes accessing same registry.
5. Process Lifecycle Management
Kill child processes on timeout to prevent zombies.
const timeout = setTimeout(() => {
proc.kill();
reject(new Error("Timeout"));
}, 30000);
try {
await proc.exited;
clearTimeout(timeout);
} catch (error) {
clearTimeout(timeout);
throw error;
}
6. Prompt Injection Sanitization
Strip control characters from user input.
function sanitizePrompt(input: string): string {
return input.replace(/[\x00-\x1F\x7F]/g, "");
}
const userPrompt = sanitizePrompt(rawInput);
7. Rollback on Failure
Delete orphaned resources if operation fails.
try {
await createNote(notePath, content);
await moveFile(source, dest);
} catch (error) {
if (pathExistsSync(notePath)) {
unlinkSync(notePath);
}
throw error;
}
Core Concepts
Confidence Scoring
| Level | Criteria |
|---|
| HIGH | Heuristics AND AI agree + target location exists + template available |
| MEDIUM | AI detects type but filename/content ambiguous |
| LOW | AI uncertain, content unclear, extraction failed |
Implementation:
let confidence: "high" | "medium" | "low" = llmResult.confidence > 0.8 ? "high" : "medium";
if (filenameHint !== llmType) {
confidence = confidence === "high" ? "medium" : "low";
}
if (!pathExistsSync(targetFolder)) {
confidence = "low";
}
if (!templateExists(suggestedNoteType)) {
confidence = confidence === "high" ? "medium" : "low";
}
Idempotency with SHA256 Registry
Use content hashing to prevent duplicate processing.
import { hashFile, createRegistry } from "./registry";
const registry = createRegistry(vaultPath);
await registry.load();
const hash = await hashFile(filePath);
if (registry.isProcessed(hash)) {
console.log("Already processed - skipping");
return;
}
registry.markProcessed({
sourceHash: hash,
sourcePath: filePath,
processedAt: new Date().toISOString(),
createdNote: notePath,
});
await registry.save();
Benefits:
- Filename changes don't break idempotency
- Safe to re-run on same files
- Registry tracks what was created from each source
Converters Architecture
Extensible document type detection via converter configuration.
The converters module provides a pluggable architecture for detecting document types:
import type { InboxConverter } from "./converters/types";
const invoiceConverter: InboxConverter = {
id: "invoice",
displayName: "Invoice",
enabled: true,
priority: 90,
heuristics: {
filenamePatterns: [
{ pattern: "invoice|rechnung|factura", weight: 0.9 },
{ pattern: "receipt|bill", weight: 0.7 },
],
contentMarkers: [
{ pattern: "total|amount due|subtotal", weight: 0.8 },
{ pattern: "invoice number|inv[.#]", weight: 0.9 },
],
threshold: 0.3,
},
fields: [
{ name: "provider", type: "string", description: "Company name", required: true },
{ name: "amount", type: "currency", description: "Total amount", required: true },
{ name: "date", type: "date", description: "Invoice date", required: true },
{ name: "invoiceNumber", type: "string", description: "Invoice #", required: false },
],
extraction: {
promptHint: "Extract invoice details including provider, amount, and date.",
keyFields: ["provider", "amount"],
},
template: {
name: "Invoice",
fieldMappings: {
provider: "Provider",
amount: "Amount",
date: "Date",
invoiceNumber: "Invoice Number",
},
},
scoring: {
heuristicWeight: 0.3,
llmWeight: 0.7,
highThreshold: 0.85,
mediumThreshold: 0.6,
},
};
Key patterns:
- Heuristics first: Quick filename/content pattern matching (0ms)
- LLM second: AI-powered extraction only for matched files (~1-3s)
- Field-driven: Each converter defines extraction fields and template mappings
- Priority-based: Higher priority converters are checked first
- Extensible: Add new document types by creating converters
Performance Characteristics
| Operation | Typical Time | Notes |
|---|
| Scan (10 PDFs) | ~15-30s | Depends on LLM latency (3 concurrent) |
| PDF extraction | ~500-2000ms | Per file, depends on size |
| LLM detection | ~1-3s | Per file (haiku model) |
| Execute (10 items) | ~2-5s | File I/O bound (10 concurrent) |
| Registry load | ~10-50ms | Depends on size (1000 items = ~50ms) |
Concurrency Limits
import pLimit from "p-limit";
const pdfLimit = pLimit(5);
const llmLimit = pLimit(3);
const ioLimit = pLimit(10);
Why limit concurrency:
- Prevent API rate limit errors
- Avoid OOM from too many parallel operations
- Balance throughput vs. resource usage
Interactive CLI
Command Loop Pattern
Display → Parse → Execute → Update display
while (true) {
console.log(formatSuggestionsTable(suggestions));
console.log("\nCommands:");
console.log(" a - Approve all HIGH confidence");
console.log(" e<N> - Edit suggestion with prompt");
console.log(" <N>,<M> - Execute specific suggestions");
console.log(" q - Quit");
const cmd = await getUserInput();
if (cmd === 'a') {
const highIds = suggestions
.filter(s => s.confidence === "high")
.map(s => s.id);
const results = await engine.execute(highIds);
suggestions = suggestions.filter(s => !highIds.includes(s.id));
}
else if (cmd.match(/^e(\d+)/)) {
const index = parseInt(cmd.slice(1));
const suggestion = suggestions[index];
const prompt = await getUserInput("Custom instructions: ");
const updated = await engine.editWithPrompt(suggestion.id, sanitizePrompt(prompt));
suggestions[index] = updated;
}
else if (cmd === 'q') {
break;
}
}
Key points:
- Stable ID-based lookups (not array indices)
- Sanitize all user input
- Update display after each operation
- Clear command structure
Related: See Bun CLI skill for argument parsing and output formatting patterns.
Formatted Output
Use tables for suggestion display:
function formatSuggestionsTable(suggestions: InboxSuggestion[]): string {
const rows = suggestions.map((s, i) => [
i.toString(),
s.confidence,
s.action,
s.suggestedTitle || s.source,
s.reason.slice(0, 50) + "...",
]);
return table([
["#", "Confidence", "Action", "Title", "Reason"],
...rows,
]);
}
Benefits:
- Scannable at a glance
- Clear column alignment
- Truncated text for readability
Error Handling
Error Taxonomy (23 Codes)
| Category | Example Codes | Recoverable? |
|---|
| dependency | DEP_PDFTOTEXT_MISSING, DEP_LLM_UNAVAILABLE | No |
| extraction | EXT_PDF_CORRUPT, EXT_PDF_EMPTY, EXT_PDF_TOO_LARGE | No |
| detection | DET_TYPE_UNKNOWN, DET_FIELDS_INCOMPLETE | No |
| validation | VAL_AREA_NOT_FOUND, VAL_TEMPLATE_MISSING | No |
| execution | EXE_NOTE_CREATE_FAILED, EXE_ATTACHMENT_MOVE_FAILED | No |
| registry | REG_READ_FAILED, REG_WRITE_FAILED, REG_CORRUPT | Yes |
| user | USR_INVALID_COMMAND, USR_EDIT_PROMPT_EMPTY | Yes |
Error Factory Pattern
interface InboxError extends Error {
code: string;
category: string;
recoverable: boolean;
context: Record<string, unknown>;
}
function createInboxError(
code: string,
context: Record<string, unknown>,
): InboxError {
const error = new Error(ERROR_MESSAGES[code]) as InboxError;
error.code = code;
error.category = code.split("_")[0].toLowerCase();
error.recoverable = RECOVERABLE_ERRORS.includes(code);
error.context = context;
return error;
}
Usage:
if (!pathExistsSync(pdfPath)) {
throw createInboxError("EXT_PDF_NOT_FOUND", {
cid,
source: pdfPath
});
}
Benefits:
- Structured error handling
- Correlation IDs for debugging
- User-facing messages separate from codes
- Recoverable vs. fatal distinction
Logging & Observability
Structured Logging
Every log includes correlation ID.
import { inboxLogger, pdfLogger, llmLogger, executeLogger } from "./logger";
const cid = crypto.randomUUID().slice(0, 8);
inboxLogger.info`Scan started items=${count} ${cid}`;
pdfLogger.debug`Extracting ${filePath} ${cid}`;
llmLogger.info`Detection complete type=${type} confidence=${conf} ${cid}`;
executeLogger.info`Note created path=${notePath} ${cid}`;
Log location: ~/.claude/logs/para-obsidian.jsonl
Key Metrics
| Metric | Purpose |
|---|
scan.duration_ms | Overall scan performance |
pdf.extraction_duration_ms | pdftotext latency |
llm.call_duration_ms | LLM API latency |
llm.calls_per_scan | Cost tracking |
execute.success_rate | Reliability |
Usage for debugging:
grep "abc12345" ~/.claude/logs/para-obsidian.jsonl
jq 'select(.llm.call_duration_ms) | .llm.call_duration_ms' \
~/.claude/logs/para-obsidian.jsonl | \
awk '{sum+=$1; count++} END {print sum/count}'
Testing Strategy
Coverage (246 Tests)
- Registry (28 tests) - Atomic writes, locking, validation, idempotency
- PDF Processor - Extraction, heuristics, TOCTOU, timeout handling
- Engine - Scan, execute, edit, rollback on failure
- CLI Adapter - Command parsing, display, prompt sanitization
- Errors - All 23 error codes, recovery strategies
- Logging - Correlation IDs, subsystem loggers
Testing Patterns
Security Testing
test("prevents command injection in PDF extraction", async () => {
const maliciousPath = "/tmp/file.pdf; rm -rf /";
await expect(extractPdfText(maliciousPath, "cid"))
.rejects.toThrow("EXT_PDF_NOT_FOUND");
});
TOCTOU Testing
test("detects file tampering during extraction", async () => {
const filePath = await createTempFile("test.pdf");
const originalStat = stat;
vi.spyOn(fs, "stat").mockImplementation(async (path) => {
const result = await originalStat(path);
result.mtimeMs += 1000;
return result;
});
await expect(extractPdfText(filePath, "cid"))
.rejects.toThrow("EXT_PDF_TOCTOU");
});
Idempotency Testing
test("doesn't reprocess same file twice", async () => {
const engine = createInboxEngine({ vaultPath });
const suggestions1 = await engine.scan();
expect(suggestions1).toHaveLength(1);
await engine.execute([suggestions1[0].id]);
const suggestions2 = await engine.scan();
expect(suggestions2).toHaveLength(0);
});
Common Questions
When should I use HIGH vs MEDIUM vs LOW confidence?
HIGH confidence requires all of:
- LLM detection confidence > 0.8
- Filename heuristics match LLM type
- Target destination folder exists
- Required template is available
MEDIUM confidence when:
- LLM is confident but heuristics disagree
- Target location exists but some ambiguity
- Most fields extracted successfully
LOW confidence when:
- LLM confidence < 0.5
- Target location doesn't exist
- Template missing
- Extraction failed or incomplete
How do I handle files that fail processing?
Use the error taxonomy to determine if recoverable:
try {
await processPDF(file);
} catch (error) {
if (error.recoverable) {
logger.warn`Recoverable error: ${error.code} ${cid}`;
} else {
logger.error`Fatal error: ${error.code} ${cid}`;
throw error;
}
}
Should I process files in CI/CD or interactively?
Interactive mode (CLI):
- Review suggestions before executing
- Edit with custom prompts
- Handle MEDIUM/LOW confidence items
CI/CD mode (future):
- Auto-execute HIGH confidence only
- Queue MEDIUM/LOW for manual review
- Generate report for human oversight
How do I debug slow LLM calls?
Check logs for correlation ID:
grep "abc12345" ~/.claude/logs/para-obsidian.jsonl | grep "llm.call_duration_ms"
jq 'select(.llm.call_duration_ms) | .llm.call_duration_ms' \
~/.claude/logs/para-obsidian.jsonl | \
awk '{sum+=$1; count++} END {print sum/count " ms"}'
Optimization strategies:
- Use faster model (haiku vs sonnet)
- Reduce concurrency limit (less rate limiting)
- Cache common vault context (areas, projects)
How do I prevent duplicate processing after renaming files?
The registry uses SHA256 content hashing, not filenames:
const hash = await sha256File("invoice-new.pdf");
if (registry.isProcessed(hash)) {
console.log("Already processed (content match)");
}
Filename changes don't affect idempotency.
What happens if a file changes during processing (TOCTOU)?
Pre- and post-checks detect tampering:
const preStats = await stat(filePath);
const text = await extractPdfText(filePath);
const postStats = await stat(filePath);
if (postStats.mtimeMs !== preStats.mtimeMs) {
throw createInboxError("EXT_PDF_TOCTOU", { cid, source: filePath });
}
If file modified during processing, operation fails safely.
Related Skills
Bun CLI Development
Reference: Bun CLI skill
Use for:
- Argument parsing patterns (--flag value, --flag=value, --flag)
- Dual output formatting (markdown + JSON)
- Error handling with exit codes
- Subcommand dispatch
- Usage text structure
Example from inbox CLI:
const { command, flags, positional } = parseArgs(process.argv.slice(2));
if (command === "process") {
const format = parseOutputFormat(flags.format);
const dryRun = flags["dry-run"] === true;
console.log(formatOutput(result, format));
}
Bun FS Helpers
Reference: Bun FS Helpers skill
Use for:
- Command-injection-safe file operations
- TOCTOU protection with stat()
- Atomic file updates (temp + rename)
- SHA256 hashing for idempotency
- Pure Bun-native APIs (no node:fs)
Example from inbox engine:
import {
pathExistsSync,
readTextFileSync,
writeTextFileSync,
rename,
sha256File,
stat,
} from "@side-quest/core/fs";
const tempPath = `${targetPath}.tmp`;
writeTextFileSync(tempPath, newContent);
await rename(tempPath, targetPath);
const hash = await sha256File(sourceFile);
if (registry.isProcessed(hash)) return;
const preStat = await stat(filePath);
const postStat = await stat(filePath);
if (postStat.mtimeMs !== preStat.mtimeMs) throw error;
Common Patterns
Engine Factory
function createInboxEngine(options: { vaultPath: string }): InboxEngine {
let cachedSuggestions: InboxSuggestion[] = [];
return {
async scan() {
cachedSuggestions = await scanInbox(options.vaultPath);
return cachedSuggestions;
},
async editWithPrompt(id: string, prompt: string) {
const suggestion = cachedSuggestions.find(s => s.id === id);
if (!suggestion) throw error;
const updated = await llmEditSuggestion(suggestion, prompt);
cachedSuggestions = cachedSuggestions.map(s =>
s.id === id ? updated : s
);
return updated;
},
async execute(ids: string[]) {
const toExecute = cachedSuggestions.filter(s => ids.includes(s.id));
const results = await Promise.all(
toExecute.map(s => executeSuggestion(s))
);
cachedSuggestions = cachedSuggestions.filter(
s => !ids.includes(s.id)
);
return results;
},
generateReport(suggestions: InboxSuggestion[]) {
return formatMarkdownReport(suggestions);
},
};
}
LLM Integration
import { buildInboxPrompt, parseDetectionResponse } from "./llm-detection";
import { callLLM } from "@sidequest/marketplace-core/llm";
async function detectDocumentType(
content: string,
filename: string,
vaultContext: { areas: string[]; projects: string[] },
): Promise<DocumentTypeResult> {
const prompt = buildInboxPrompt({
content,
filename,
vaultContext,
});
const response = await callLLM(prompt, {
model: "haiku",
temperature: 0.3,
});
return parseDetectionResponse(response);
}
Quick Reference
File Structure
src/inbox/
├── types.ts # Core types (InboxSuggestion, InboxEngine)
├── engine.ts # Engine factory (scan/execute/edit/report)
├── registry.ts # Idempotency tracking (SHA256, locking)
├── pdf-processor.ts # PDF extraction + heuristics
├── llm-detection.ts # AI type detection + field extraction
├── cli-adapter.ts # Interactive terminal UI
├── cli.ts # Interactive CLI entry point
├── errors.ts # Error taxonomy (23 codes)
├── logger.ts # Structured logging with correlation IDs
├── unique-path.ts # Path collision detection and resolution
├── converters/ # Document type detection configuration
│ ├── types.ts # InboxConverter interface definitions
│ ├── defaults.ts # Default converters (invoice, booking)
│ ├── loader.ts # Converter loading and merging
│ └── index.ts # Module exports
└── [*.test.ts] # 246 comprehensive tests (10 files)
Key Dependencies
p-limit - Controlled concurrency
nanospinner - Progress indicators for CLI
@side-quest/core/fs - Atomic write utilities (ensureDirSync, moveFile, readTextFileSync)
@sidequest/core/glob - File globbing utilities (globFilesSync)
pdftotext - External CLI (brew install poppler)
crypto.subtle - SHA256 hashing (Bun native)
Checklist: Building an Inbox Processor
Last Updated: 2025-12-12
Status: Production Reference Implementation
Related: Bun CLI, Bun FS Helpers