AI writing assistants need concrete examples of the desired output style. A file-based style system solves this by organizing style guides and sample outputs as plain files on disk, using a convention-over-configuration approach. Format loaders walk a directory tree under styles/formats/<format>/examples, sort files by numeric prefix to identify the most recent samples, honor per-format overrides, and cache master guides and glossaries for reuse across generation requests.
This file-based approach has a key advantage over database-backed alternatives: style guides are version-controlled alongside the code. When a team updates their brand voice or adds a new content format, the changes are tracked in Git, reviewed in pull requests, and deployed with the same CI/CD pipeline as the application. There is no separate admin UI, no migration scripts, and no risk of database/code drift.
The recency-sorting mechanism ensures that the AI model sees the most current examples of the desired style. As writing conventions evolve, newer examples naturally take priority over older ones. By keeping only the 3–5 most recent samples per format in the prompt context, the system avoids overwhelming the model with contradictory examples from different eras while still providing enough variety to capture the style’s range.
The style system follows a fixed directory layout. Each content format (blog post, newsletter, product update, etc.) has its own directory under styles/formats/. Within each format directory, an examples/ subdirectory contains sample outputs, a guide.md file describes the format’s rules, and an optional overrides.json configures format-specific behavior like token limits or temperature settings.
styles/
master-guide.md # Global writing rules (brand voice, tone)
glossary.md # Approved terminology and definitions
formats/
blog-post/
guide.md # Blog-specific rules (length, structure)
overrides.json # { "maxTokens": 2000, "temperature": 0.7 }
examples/
001_intro_post.md
002_deep_dive.md
003_release_notes.md
newsletter/
guide.md
examples/
001_weekly_digest.md
002_product_update.md
product-update/
guide.md
examples/
001_feature_launch.md
002_bugfix_summary.md
The directory structure is the configuration. There is no config file listing available formats—the format loader discovers them by walking the styles/formats/ directory. Adding a new format means creating a directory with the expected structure. Removing a format means deleting its directory. This eliminates an entire category of config-vs-reality drift bugs.
Example files are named with a numeric prefix (e.g., 001_intro_post.md, 042_quarterly_review.md). The loader extracts the numeric prefix, parses it as an integer, and sorts examples in descending order. The highest-numbered files are the most recent and are selected first for inclusion in the prompt. This convention avoids the need for file modification timestamps, which are unreliable across Git clones and CI environments.
Git does not preserve file modification times. When a repository is cloned, all files get the clone timestamp. When a file is checked out, it gets the checkout timestamp. Numeric prefixes are embedded in the filename and travel with the file through any Git operation, CI pipeline, or container build. They are the only reliable ordering mechanism in a version-controlled filesystem.
Each format can define an overrides.json that adjusts generation parameters. A blog post format might set a higher temperature for creative variation, while a product update format might set a lower temperature for consistency. The loader merges format-level overrides with global defaults, with format-level values taking precedence.
The master guide (styles/master-guide.md) and glossary (styles/glossary.md) are shared across all formats. Since they are read on every generation request, the loader caches them in memory with a file-watcher that invalidates the cache when the files change. This avoids redundant filesystem reads without requiring a restart when guides are updated.
The final prompt context is assembled in layers: master guide (brand voice) + glossary (terminology) + format guide (structure rules) + recent examples (style demonstration). Each layer can be updated independently. A glossary change propagates to all formats immediately, while a format guide change only affects that specific format.
The format loader is the central component that discovers formats, loads their configuration, and assembles the prompt context for a given generation request.
styles/formats/ and registers each subdirectory as an available format.guide.md is read and associated with the format.examples/ directory is scanned, files are sorted by numeric prefix in descending order, and the top N (default 3–5) are retained.import fs from "node:fs";
import path from "node:path";
interface FormatConfig {
name: string;
guide: string;
examples: string[];
overrides: Record<string, unknown>;
}
interface StyleContext {
masterGuide: string;
glossary: string;
formatGuide: string;
examples: string[];
overrides: Record<string, unknown>;
}
class FormatLoader {
private stylesDir: string;
private masterGuide: string = "";
private glossary: string = "";
private formats: Map<string, FormatConfig> = new Map();
private maxExamples: number;
constructor(stylesDir: string, maxExamples = 5) {
this.stylesDir = stylesDir;
this.maxExamples = maxExamples;
this.load();
}
private load(): void {
// Cache master guide and glossary.
this.masterGuide = this.readFile("master-guide.md");
this.glossary = this.readFile("glossary.md");
// Discover formats.
const formatsDir = path.join(this.stylesDir, "formats");
for (const entry of fs.readdirSync(formatsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const formatDir = path.join(formatsDir, entry.name);
const guide = this.readFileOr(path.join(formatDir, "guide.md"), "");
const overrides = this.readJsonOr(
path.join(formatDir, "overrides.json"),
{}
);
const examples = this.loadExamples(
path.join(formatDir, "examples")
);
this.formats.set(entry.name, {
name: entry.name,
guide,
examples,
overrides,
});
}
}
private loadExamples(dir: string): string[] {
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
// Sort by numeric prefix, descending (most recent first).
files.sort((a, b) => {
const numA = parseInt(a.match(/^(\d+)/)?.[1] || "0", 10);
const numB = parseInt(b.match(/^(\d+)/)?.[1] || "0", 10);
return numB - numA;
});
// Take the most recent N examples.
return files
.slice(0, this.maxExamples)
.map((f) => fs.readFileSync(path.join(dir, f), "utf-8"));
}
getContext(formatName: string): StyleContext {
const format = this.formats.get(formatName);
if (!format) {
throw new Error(`Unknown format: ${formatName}`);
}
return {
masterGuide: this.masterGuide,
glossary: this.glossary,
formatGuide: format.guide,
examples: format.examples,
overrides: format.overrides,
};
}
}
// Assembling the prompt from style context.
function buildPrompt(ctx: StyleContext, userRequest: string): string {
const sections: string[] = [];
sections.push("## Brand Voice & Style Guide\n" + ctx.masterGuide);
if (ctx.glossary) {
sections.push("## Glossary\n" + ctx.glossary);
}
sections.push("## Format Guide\n" + ctx.formatGuide);
if (ctx.examples.length > 0) {
sections.push(
"## Recent Examples\n" +
ctx.examples
.map((ex, i) => `### Example ${i + 1}\n${ex}`)
.join("\n\n")
);
}
sections.push("## Your Task\n" + userRequest);
return sections.join("\n\n---\n\n");
}
Agencies managing multiple brands can maintain separate styles/ directories per brand, each with its own master guide, glossary, and format examples. The same generation pipeline serves all brands by switching which style directory is loaded based on the request.
When a brand’s voice evolves (e.g., shifting from formal to conversational), the team adds new examples with higher numeric prefixes. The old examples naturally fall out of the selection window. There is no model retraining, no database migration, and no config change—just new files in Git.
By maintaining multiple format variants (e.g., blog-post-formal/ and blog-post-casual/), the system can generate the same content brief in different styles. The variants can be A/B tested with audiences, and the winning style becomes the default by renaming the directory.
In development, a file watcher (using fs.watch or chokidar) monitors the styles directory for changes. When a guide or example is modified, the cached content is invalidated and reloaded on the next request. This enables rapid iteration: edit a guide, regenerate content, and see the effect immediately.
import chokidar from "chokidar";
class HotReloadableLoader extends FormatLoader {
private watcher: chokidar.FSWatcher;
constructor(stylesDir: string) {
super(stylesDir);
this.watcher = chokidar.watch(stylesDir, {
ignoreInitial: true,
persistent: true,
});
this.watcher.on("change", (filePath) => {
console.log(`Style file changed: ${filePath}, reloading...`);
this.reload();
});
this.watcher.on("add", () => this.reload());
this.watcher.on("unlink", () => this.reload());
}
}
The prompt context has a finite token budget. The loader allocates tokens across layers: master guide gets a fixed budget, the glossary gets a fixed budget, and the remaining tokens are split between the format guide and examples. If the examples are too long to fit, the loader trims the oldest examples first and truncates the remaining ones at paragraph boundaries.
function fitToTokenBudget(
ctx: StyleContext,
totalBudget: number,
tokenizer: Tokenizer
): StyleContext {
const masterTokens = tokenizer.count(ctx.masterGuide);
const glossaryTokens = tokenizer.count(ctx.glossary);
const guideTokens = tokenizer.count(ctx.formatGuide);
const fixedTokens = masterTokens + glossaryTokens + guideTokens;
let exampleBudget = totalBudget - fixedTokens;
const fittedExamples: string[] = [];
for (const example of ctx.examples) {
const exTokens = tokenizer.count(example);
if (exampleBudget - exTokens < 0) break;
fittedExamples.push(example);
exampleBudget -= exTokens;
}
return { ...ctx, examples: fittedExamples };
}
20240315_post.md), the sort still works but gaps in the sequence can confuse contributors who expect sequential numbering. Establish a convention early and document it in the format’s guide.md.fs.watch can accumulate watchers that are never cleaned up. Use a library like chokidar that handles watcher lifecycle management, and always call close() on shutdown.