ProcessWire AI Integration Module — complete API reference, 25 real-world examples, and implementation guides.
This document is designed for both human developers and AI coding assistants (Cursor, Copilot, Claude Code, etc.) to understand the module’s full capabilities and implement it correctly.
chat(string $message, array $options = []): stringReturns just the AI response text. Empty string on error. Use this for simple cases where you just need the answer.
$ai = $modules->get('AiWire');
$text = $ai->chat('Suggest a tagline for a bakery website');
// "Fresh from our oven to your table — taste the difference."
ask(string $message, array $options = []): arrayReturns the full structured response with metadata.
$result = $ai->ask('Translate "hello" to 10 languages');
// Response structure:
[
'success' => true, // bool — did it work?
'content' => '...', // string — AI response text
'message' => 'OK', // string — status message or error
'usage' => [
'input_tokens' => 15, // tokens in your prompt
'output_tokens' => 230, // tokens in the response
'total_tokens' => 245, // total tokens consumed
],
'raw' => [ ... ], // full raw API response
]
askWithFallback(string $message, array $options = []): arrayTries all enabled keys for the primary provider, then falls back to other providers. Returns extra fields: usedProvider, usedKeyIndex, usedKeyLabel.
$result = $ai->askWithFallback('Summarize this article...', [
'provider' => 'anthropic',
'fallbackProviders' => ['openai', 'google'],
]);
// $result['usedProvider'] tells you which provider actually responded
askMultiple(string $message, array $providers, array $options = []): arraySends the same message to multiple providers. Returns an associative array keyed by provider name.
$results = $ai->askMultiple('What is love?', ['anthropic', 'openai', 'xai']);
// $results['anthropic'] => [...], $results['openai'] => [...], etc.
getProvider(string $providerKey, ?string $specificKey, ?int $keyIndex): ?AiWireProviderGet a raw provider instance for advanced usage.
$provider = $ai->getProvider('anthropic');
$testResult = $provider->testConnection();
getProvidersStatus(): arrayGet status overview of all providers and their key counts.
$status = $ai->getProvidersStatus();
// ['anthropic' => ['label' => 'Anthropic (Claude)', 'active' => true, 'keyCount' => 2], ...]
cache(): AiWireCacheGet the cache instance for direct access.
clearCache(int|Page $page = 0): intClear all cached responses for a specific page. Returns number of files deleted.
$ai->clearCache($page); // clear cache for this page
$ai->clearCache(1042); // clear by page ID
$ai->clearCache(0); // clear global cache (no page context)
clearAllCache(): intClear all AiWire cached responses across all pages.
cacheStats(): arrayGet cache statistics: total files, total size, pages count, expired count.
saveTo(Page $page, string $fieldName, string|array $content, bool $quiet = true): boolSave AI content to a page field. Accepts a string or a full ask() result array.
loadFrom(Page $page, string $fieldName): ?stringLoad content from a page field. Returns null if empty.
askAndSave(Page $page, string|array $fields, ?string $message, array $options = []): arrayAsk AI only if the field is empty — otherwise return existing content. Three calling modes:
// Single field
$ai->askAndSave($page, 'seo_desc', 'Write SEO for: ...');
// Multiple fields, same prompt (AI called once, saved to all empty fields)
$ai->askAndSave($page, ['seo_desc', 'og_description'], 'Write SEO for: ...');
// Batch: each field gets its own prompt
$ai->askAndSave($page, [
'seo_desc' => 'Write SEO description for: ...',
'ai_summary' => 'Summarize: ...',
'ai_keywords' => 'Extract 5 keywords from: ...',
]);
Single field returns one result with 'source' => 'field'|'ai'. Multi/batch returns ['field_name' => result, ...].
generate(Page $page, array $blocks, array $globalOptions = []): arrayGenerate multiple AI content blocks for a page. Each block has its own prompt, field, and optional per-block settings (provider, model, temperature, etc.). Global options apply unless overridden per block.
$ai->generate($page, [
['field' => 'ai_overview', 'prompt' => '...'],
['field' => 'ai_facts', 'prompt' => '...', 'options' => ['provider' => 'openai']],
], ['temperature' => 0.5, 'cache' => 'W']);
Returns ['field_name' => result, ...] with source: 'field'|'ai'|'error'.
Every ask(), askWithFallback(), askAndSave(), and generate() call returns a structured array:
// Successful response
[
'success' => true,
'content' => 'The AI response text...',
'message' => 'OK',
'usage' => [
'input_tokens' => 25,
'output_tokens' => 148,
'total_tokens' => 173,
],
'raw' => [ /* full API response from provider */ ],
'cached' => false, // true if served from file cache
'source' => 'ai', // only in askAndSave/generate: 'ai', 'field', or 'error'
]
// Failed response
[
'success' => false,
'content' => '',
'message' => 'Error: rate limit exceeded',
'usage' => [],
'raw' => [ /* raw error data */ ],
]
chat() returns just the text string (or empty string on error):
$text = $ai->chat('Summarize this'); // "Here is a summary..."
askWithFallback() adds extra fields on success:
$result['usedProvider'] // 'openai' — which provider actually responded
$result['usedKeyIndex'] // 0 — which key index was used
$result['usedKeyLabel'] // 'Production key' — human-readable key label from admin
generate() and batch askAndSave() return results keyed by field name:
$results = $ai->generate($page, [
['field' => 'ai_overview', 'prompt' => '...'],
['field' => 'ai_summary', 'prompt' => '...'],
]);
// $results:
[
'ai_overview' => ['success' => true, 'content' => '...', 'source' => 'ai', ...],
'ai_summary' => ['success' => true, 'content' => '...', 'source' => 'field', ...],
// ^ already existed, no API call
]
Every method that accepts $options supports these parameters:
| Option | Type | Default | Description |
|---|---|---|---|
provider |
string | Module default | anthropic, openai, google, xai, openrouter |
model |
string | Key’s model | Override model for this call |
systemPrompt |
string | Module default | System instructions for the AI |
maxTokens |
int | 1024 | Max tokens in response |
temperature |
float | 0.7 | Creativity: 0.0 = precise, 1.0+ = creative |
history |
array | [] |
Previous messages for multi-turn |
key |
string | — | Use a specific API key string |
keyIndex |
int | — | Use a specific key by its index (0-based) |
fallbackProviders |
array | — | For askWithFallback — list of fallback providers |
cache |
string|int | false |
Cache TTL: 'D', 'W', 'M', 'Y', '2W', '3M', or seconds |
pageId |
int|Page | 0 | Page context for cache (groups cache files by page) |
timeout |
int | 30 | Request timeout in seconds |
overwrite |
bool | false | For askAndSave — always call AI even if field has content |
quiet |
bool | true | For askAndSave — save without triggering PW hooks |
| Model ID | Name |
|---|---|
claude-opus-4-6 |
Claude Opus 4.6 |
claude-sonnet-4-5-20250929 |
Claude Sonnet 4.5 |
claude-haiku-4-5-20251001 |
Claude Haiku 4.5 |
| Model ID | Name |
|---|---|
gpt-5.2 |
GPT-5.2 |
gpt-5-mini |
GPT-5 Mini |
gpt-5-nano |
GPT-5 Nano |
gpt-4.1 |
GPT-4.1 |
| Model ID | Name |
|---|---|
gemini-3-pro-preview |
Gemini 3 Pro Preview |
gemini-flash-latest |
Gemini Flash |
gemini-flash-lite-latest |
Gemini Flash Lite |
| Model ID | Name |
|---|---|
grok-4-1-fast-reasoning |
Grok 4.1 Fast (Reasoning) |
grok-4-1-fast-non-reasoning |
Grok 4.1 Fast |
grok-3-mini |
Grok 3 Mini |
| Company | Model ID | Name |
|---|---|---|
| Amazon | amazon/nova-micro-v1 |
Nova Micro |
| Amazon | amazon/nova-2-lite-v1 |
Nova 2 Lite |
| Anthropic | anthropic/claude-sonnet-4.5 |
Claude Sonnet 4.5 (via OR) |
| ByteDance | bytedance-seed/seed-1.6 |
Seed 1.6 |
| DeepSeek | deepseek/deepseek-v3.2 |
DeepSeek V3.2 |
google/gemini-3-flash-preview |
Gemini 3 Flash Preview | |
google/gemini-2.5-flash |
Gemini 2.5 Flash | |
| Meta | meta-llama/llama-4-maverick |
Llama 4 Maverick |
| Meta | meta-llama/llama-3.3-70b-instruct |
Llama 3.3 70B |
| MiniMax | minimax/minimax-m2.1 |
MiniMax M2.1 |
| Mistral | mistralai/devstral-2512 |
Devstral 2512 |
| Mistral | mistralai/mistral-small-3.2-24b-instruct |
Mistral Small 3.2 24B |
| NVIDIA | nvidia/nemotron-3-nano-30b-a3b |
Nemotron 3 Nano 30B |
| OpenAI | openai/gpt-5.2 |
GPT-5.2 (via OR) |
| Qwen (Alibaba) | qwen/qwen3-max-thinking |
Qwen 3 Max Thinking |
| Xiaomi | xiaomi/mimo-v2-flash |
MiMo V2 Flash |
| xAI | x-ai/grok-4-1-fast |
Grok 4.1 Fast (via OR) |
| Zhipu AI | z-ai/glm-4.7 |
GLM 4.7 |
| Zhipu AI | z-ai/glm-5 |
GLM 5 |
Tip: OpenRouter gives you access to all providers through a single API key. Useful if you want to test different models without managing separate accounts.
All examples below are based on a real-world alcohol/spirits catalog site (lqrs.com) built with ProcessWire. The site has templates like product, brand, category, cocktail, region, and article. Adapt field names and templates to your own project.
Problem: Product pages need rich content (overview, brand story, food pairings, serving guide) but writing it manually for hundreds of products is impossible.
generate()creates all blocks at once — each with its own prompt and settings — saving content to page fields so AI only runs once per product.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | title (Text), body (Textarea), brand (Page ref → brand), region (Page ref → region), abv (Float), volume (Integer), tasting_notes (Textarea) |
| AI fields | ai_overview (Textarea), ai_brand_story (Textarea), ai_food_pairing (Textarea), ai_serving_guide (Textarea) |
| File | site/templates/product.php |
// site/templates/product.php — e.g. "2015 Louis Roederer Cristal"
$ai = $modules->get('AiWire');
$body = strip_tags($page->body);
$results = $ai->generate($page, [
[
'field' => 'ai_overview',
'prompt' => "Write a detailed overview of {$page->title}. "
. "Include flavor profile, aging process, and what makes this product special. "
. "Category: {$page->parent->title}. "
. "Brand: {$page->brand->title}. "
. "Region: {$page->region->title}. "
. "ABV: {$page->abv}%. Volume: {$page->volume}ml.",
'options' => ['maxTokens' => 600, 'temperature' => 0.6],
],
[
'field' => 'ai_brand_story',
'prompt' => "Share 3 interesting facts about {$page->brand->title} that most people don't know. "
. "Be engaging, surprising. Start each fact with a bold statement.",
'systemPrompt' => 'You are a spirits historian. Write in a friendly, conversational tone. '
. 'Focus on heritage, craftsmanship, and unique traditions.',
'options' => ['maxTokens' => 500],
],
[
'field' => 'ai_food_pairing',
'prompt' => "Suggest 5 specific food pairings for {$page->title} ({$page->parent->title}). "
. "For each pairing explain WHY it works in one sentence. "
. "Consider the flavor profile: {$page->tasting_notes}.",
'options' => ['temperature' => 0.5, 'maxTokens' => 400],
],
[
'field' => 'ai_serving_guide',
'prompt' => "Write a brief serving guide for {$page->title}. "
. "Cover: ideal temperature, glassware, decanting (if applicable), "
. "and the best occasion to enjoy it.",
'options' => ['provider' => 'google', 'model' => 'gemini-flash-lite-latest', 'maxTokens' => 300],
],
], [
'cache' => 'M',
'temperature' => 0.7,
]);
// Output in template
foreach (['ai_overview', 'ai_brand_story', 'ai_food_pairing', 'ai_serving_guide'] as $field) {
if (isset($results[$field]) && $results[$field]['success']) {
echo "<section class='{$field}'>{$results[$field]['content']}</section>";
}
}
Result — saved to page fields, rendered in template:
<section class="ai_overview">
Louis Roederer Cristal 2015 is a prestige cuvée champagne that represents the pinnacle
of the house's winemaking artistry. Aged for six years on the lees, this vintage delivers
an extraordinary complexity — notes of candied citrus, white flowers, and toasted brioche
unfold gradually, supported by a chalky minerality...
</section>
<section class="ai_brand_story">
Louis Roederer was the first champagne house to own all its vineyards outright.
In 1876, Tsar Alexander II demanded a clear crystal bottle so no one could hide
poison — and Cristal was born as history's first prestige cuvée...
</section>
<section class="ai_food_pairing">
1. Grilled lobster with drawn butter — the wine's citrus acidity cuts through
the richness of the butter while complementing the sweet shellfish...
</section>
<section class="ai_serving_guide">
Serve at 10-12°C in a tulip-shaped white wine glass to concentrate the delicate aromas.
No decanting needed, but open 15 minutes before serving to let the wine breathe...
</section>
Each block is generated once by AI, saved to the page field, and on subsequent requests served instantly from the database — no API call.
Problem: Every product needs a unique meta description and OG title for search engines and social sharing, but editors skip this step. This hook auto-generates SEO fields whenever a product is saved, so every page is search-ready without manual effort.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | title (Text), body (Textarea), seo_description (Text, maxlength=160), og_title (Text, maxlength=60) |
| File | site/ready.php |
// site/ready.php
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'product') return;
if (!$page->isChanged('title') && !$page->isChanged('body')) return;
$ai = $this->modules->get('AiWire');
$ai->askAndSave($page, [
'seo_description' => "Write an SEO meta description (max 155 chars) for this product listing. "
. "Include the product name and one compelling selling point.\n\n"
. "Product: {$page->title}\n"
. "Category: {$page->parent->title}\n"
. "Content: " . mb_substr(strip_tags($page->body), 0, 1000),
'og_title' => "Write a compelling social media title (max 60 chars) for: {$page->title}. "
. "Make it enticing and shareable. Return ONLY the title text.",
], null, [
'overwrite' => true,
'maxTokens' => 100,
'temperature' => 0.4,
]);
});
Result — fields saved to the product page after editor clicks Save:
$page->seo_description = "Discover the 2015 Louis Roederer Cristal — a prestige champagne
with six years of aging, delivering citrus and brioche elegance."
$page->og_title = "2015 Cristal: Six Years of Champagne Perfection"
Editor sees a notification: “AI generated SEO fields”. Both fields are editable in the admin if the editor wants to tweak them.
Problem: Brand pages feel thin — just a logo and product list. Writing history, highlights, and FAQs for every brand is a huge content effort.
generate()fills brand pages with rich AI content that references their actual product lineup.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | brand (parent of product pages) |
| Fields | title (Text), country (Page ref → country) |
| AI fields | ai_brand_history (Textarea), ai_brand_highlights (Textarea), ai_brand_faq (Textarea) |
| File | site/templates/brand.php |
// site/templates/brand.php — e.g. "Chivas Regal", "Louis Roederer"
$ai = $modules->get('AiWire');
$productList = '';
foreach ($page->children("limit=20") as $p) {
$productList .= "- {$p->title} ({$p->parent->title}, {$p->abv}%)\n";
}
$results = $ai->generate($page, [
[
'field' => 'ai_brand_history',
'prompt' => "Write a concise history of {$page->title} (alcohol brand). "
. "Cover founding, key milestones, and what defines their style. "
. "Country: {$page->country->title}. 2-3 paragraphs.",
'options' => ['maxTokens' => 600, 'temperature' => 0.5],
],
[
'field' => 'ai_brand_highlights',
'prompt' => "Based on this product lineup, write 3 reasons why {$page->title} stands out:\n\n"
. $productList . "\n"
. "Be specific. Reference actual products from the list.",
'options' => ['maxTokens' => 400],
],
[
'field' => 'ai_brand_faq',
'prompt' => "Write 5 frequently asked questions about {$page->title} with short answers. "
. "Include: origin, flagship product, best for beginners, price range, how to drink.",
'systemPrompt' => 'Format each Q&A as: **Q: question**\nA: answer\n',
'options' => ['maxTokens' => 500, 'temperature' => 0.4],
],
], ['cache' => 'M']);
Result — three content sections populated on the brand page:
ai_brand_history:
"Founded in 1776 in Reims, France, Louis Roederer remains one of the last
major family-owned champagne houses. Under the direction of Frédéric Rouzaud,
the seventh generation, the house cultivates 240 hectares of Grand and Premier
Cru vineyards — an unusual commitment to estate-grown fruit..."
ai_brand_highlights:
"1. Cristal 2015 stands as the flagship — a prestige cuvée with six years of lees aging
2. The Brut Premier NV offers exceptional value as an everyday champagne
3. Unlike most houses, 70% of their grapes are estate-grown..."
ai_brand_faq:
"**Q: Where is Louis Roederer from?**
A: Reims, Champagne, France — founded in 1776.
**Q: What is their flagship product?**
A: Cristal, originally created in 1876 for Tsar Alexander II..."
Problem: Category pages (Whiskey, Vodka, Red Wine…) need SEO-friendly descriptions, but they rarely get written because there are dozens of categories. This hook generates a description automatically the first time a category is saved.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | category (parent of product pages) |
| Fields | title (Text), ai_description (Textarea) |
| File | site/ready.php |
// site/ready.php — auto-generate descriptions for category pages (Whiskey, Vodka, Red Wine, etc.)
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'category') return;
if ($page->ai_description) return; // already has content
$ai = $this->modules->get('AiWire');
$productCount = $page->children->count();
$topProducts = $page->children("sort=-views, limit=5")->implode(', ', 'title');
$ai->askAndSave($page, 'ai_description',
"Write an engaging category description for a '{$page->title}' section "
. "of an online spirits and wine store. "
. "We have {$productCount} products including: {$topProducts}. "
. "Write 2 paragraphs: first about the category in general, "
. "second about what makes our selection special. "
. "Do NOT list products. Do NOT use bullet points.",
['maxTokens' => 400, 'temperature' => 0.6]
);
});
Result — the category page now has an SEO-friendly description:
ai_description:
"Whiskey is a spirit of remarkable depth, shaped by grain, water, and time in
oak barrels. From the smoky peat of Islay single malts to the caramel sweetness
of Kentucky bourbon, each bottle tells a story of terroir and tradition.
Our collection of 127 whiskeys spans the world's most celebrated distilleries.
Whether you're discovering your first single malt or hunting for a rare cask-strength
release, you'll find expressions from Scotland, Ireland, Japan, and the American
heartland — all selected for character and quality."
Problem: Each cocktail page needs an intro, step-by-step instructions, tips, and variations — too much manual writing for a cocktail database.
generate()produces all recipe sections from the ingredient list, giving each cocktail a complete write-up.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | cocktail |
| Fields | title (Text), ingredients (Page ref multiple → ingredient, or RepeaterMatrix) |
| AI fields | ai_recipe_intro (Textarea), ai_recipe_steps (Textarea), ai_recipe_tips (Textarea), ai_recipe_variations (Textarea) |
| File | site/templates/cocktail.php |
// site/templates/cocktail.php — e.g. "Freddie Bartholomew", "Arnold Palmer Mocktail"
$ai = $modules->get('AiWire');
$ingredients = $page->ingredients->implode(', ', 'title'); // RepeaterMatrix or PageArray
$results = $ai->generate($page, [
[
'field' => 'ai_recipe_intro',
'prompt' => "Write a 2-sentence intro for the '{$page->title}' cocktail. "
. "Ingredients: {$ingredients}. "
. "Mention the origin or inspiration behind this drink if known.",
'options' => ['maxTokens' => 150, 'temperature' => 0.7],
],
[
'field' => 'ai_recipe_steps',
'prompt' => "Write step-by-step mixing instructions for '{$page->title}'. "
. "Ingredients: {$ingredients}. "
. "Include preparation, mixing technique, garnish, and serving glass. "
. "Number each step.",
'options' => ['maxTokens' => 400, 'temperature' => 0.3],
],
[
'field' => 'ai_recipe_tips',
'prompt' => "Write 3 pro tips for making the perfect '{$page->title}'. "
. "Include: a substitution idea, a presentation trick, and a common mistake to avoid.",
'options' => ['maxTokens' => 300],
],
[
'field' => 'ai_recipe_variations',
'prompt' => "Suggest 3 creative variations of '{$page->title}'. "
. "Original ingredients: {$ingredients}. "
. "For each variation give a fun name and what to change.",
'options' => ['maxTokens' => 300, 'temperature' => 0.8],
],
], ['cache' => 'W']);
Result — the cocktail page gets four complete sections:
ai_recipe_intro:
"The Freddie Bartholomew is a refreshing mocktail that blends crisp apple juice
with bright lemon and the gentle warmth of ginger ale, named after the beloved
child actor of the 1930s Golden Age of Hollywood."
ai_recipe_steps:
"1. Fill a highball glass with ice cubes
2. Pour 120ml apple juice and 30ml fresh lemon juice over the ice
3. Top with 90ml chilled ginger ale and stir gently
4. Garnish with a thin apple slice and a lemon wheel
5. Serve immediately with a paper straw"
ai_recipe_tips:
"• Swap ginger ale for ginger beer if you prefer a spicier kick
• Freeze apple juice in ice cube trays — they keep the drink cold without dilution
• Don't shake carbonated ingredients; always stir gently to preserve the fizz"
ai_recipe_variations:
"1. 'The Smoky Freddie' — add 15ml smoked simple syrup and garnish with a rosemary sprig
2. 'Tropical Bartholomew' — replace apple juice with mango nectar and add passion fruit
3. 'Winter Freddie' — warm the apple juice, add cinnamon and star anise, skip the ginger ale"
Problem: Wine region pages need educational content about geography, climate, and traditions — plus personalized recommendations from your actual catalog. This generates a complete region guide enriched with products you actually sell.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | region (referenced by products via Page ref) |
| Fields | title (Text), ai_region_overview (Textarea), ai_region_recommendations (Textarea) |
| Relation | product template has region (Page ref → region) |
| File | site/templates/region.php |
// site/templates/region.php — e.g. "Champagne", "Tuscany", "Islay"
$ai = $modules->get('AiWire');
$products = $pages->find("template=product, region={$page->id}, limit=30");
$productList = $products->implode("\n", function($p) {
return "- {$p->title} by {$p->brand->title} ({$p->parent->title})";
});
$results = $ai->generate($page, [
[
'field' => 'ai_region_overview',
'prompt' => "Write a guide to {$page->title} as a wine/spirits region. "
. "Country: {$page->parent->title}. "
. "Cover: geography, climate, key grape varieties or distillation traditions, "
. "and what makes products from this region distinctive. 3 paragraphs.",
'options' => ['maxTokens' => 600, 'temperature' => 0.5],
],
[
'field' => 'ai_region_recommendations',
'prompt' => "From this product list, pick 5 standout products and explain "
. "why each one is worth trying. Be specific about flavors and occasions.\n\n"
. $productList,
'options' => ['maxTokens' => 500, 'temperature' => 0.6],
],
], ['cache' => 'M']);
Result — the region page is enriched with educational content:
ai_region_overview:
"Champagne, the northernmost wine region of France, sits on a unique bed of
chalk and limestone that imparts a distinctive minerality to its sparkling wines.
The cool continental climate, with average temperatures just above the minimum
for grape ripening, creates the high acidity that gives Champagne its signature
freshness and aging potential.
Three grape varieties dominate: Chardonnay for elegance, Pinot Noir for body,
and Pinot Meunier for fruitiness. The méthode champenoise — secondary fermentation
in the bottle — transforms still wine into the world's most celebrated sparkling wine..."
ai_region_recommendations:
"1. Louis Roederer Cristal 2015 — the benchmark prestige cuvée, worth every penny
for a special celebration. Expect white flowers, citrus, and extraordinary length.
2. Bollinger Special Cuvée NV — a Pinot Noir-dominant blend with toasty richness,
perfect for pairing with roast chicken or aged cheeses..."
Problem: A product with 50+ reviews is hard to scan. Customers want a quick summary — what people love, what they don’t, and who this product is best for. AI summarizes all reviews into a concise paragraph, saved to the product page.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | reviews (Repeater/RepeaterMatrix: rating Integer, body Textarea, author Text), ai_review_summary (Textarea) |
| File | site/templates/product.php |
// site/templates/product.php — summarize user reviews
$ai = $modules->get('AiWire');
$reviews = $page->reviews; // RepeaterMatrix: rating, body, author
if ($reviews->count() >= 3) {
$reviewText = '';
foreach ($reviews as $r) {
$reviewText .= "Rating: {$r->rating}/5 by {$r->author}: {$r->body}\n---\n";
}
$ai->askAndSave($page, 'ai_review_summary',
"Analyze these {$reviews->count()} customer reviews and write a summary. "
. "Include: average sentiment, most praised qualities, any common complaints, "
. "and who this product is best for. 2-3 sentences.\n\n"
. $reviewText,
[
'maxTokens' => 250,
'temperature' => 0.3,
'cache' => 'W',
]
);
}
Result — a concise summary replaces 50+ individual reviews:
ai_review_summary:
"Across 47 reviews averaging 4.6/5, customers consistently praise the Cristal 2015's
exceptional balance of citrus freshness and toasty complexity, with many noting it
outperforms its price point. The most common complaint is limited availability.
Best suited for collectors and special-occasion drinkers who appreciate elegant,
food-friendly champagne."
Problem: User-submitted reviews can contain spam, hate speech, or fake content — manual moderation doesn’t scale. AI checks every review before publication, auto-unpublishing flagged content with a reason for the moderator.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | review (child of product) |
| Fields | body (Textarea), moderation_note (Text, hidden from frontend) |
| File | site/ready.php |
// site/ready.php
$wire->addHookBefore('Pages::saveReady', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'review') return;
if (!$page->isChanged('body')) return;
$ai = $this->modules->get('AiWire');
$result = $ai->ask(
"Analyze this product review for an alcohol/spirits store. Check for:\n"
. "1. Spam or irrelevant content\n"
. "2. Hate speech or harassment\n"
. "3. Fake review patterns\n"
. "4. References to underage drinking\n"
. "5. Personal information (phone numbers, addresses)\n\n"
. "Reply ONLY with JSON: {\"safe\": true/false, \"reason\": \"...\"}\n\n"
. "Review: {$page->body}",
[
'maxTokens' => 100,
'temperature' => 0,
'provider' => 'openai',
'model' => 'gpt-5-nano',
]
);
if ($result['success']) {
$analysis = json_decode($result['content'], true);
if ($analysis && !$analysis['safe']) {
$page->addStatus(Page::statusUnpublished);
$page->moderation_note = $analysis['reason'];
$this->warning("Review flagged by AI: {$analysis['reason']}");
}
}
});
Result — flagged review is auto-unpublished:
// AI returns: {"safe": false, "reason": "Promotional spam: contains external store URLs"}
// ProcessWire admin shows warning: "Review flagged by AI: Promotional spam: contains external store URLs"
// The review page status is set to Unpublished
// moderation_note field stores the reason for moderator reference
Safe reviews pass through without any changes. Only flagged reviews require moderator attention.
Problem: Expanding to international markets means translating hundreds of product descriptions — too expensive for human translators on every product. LazyCron finds products with empty translation fields and fills them automatically using Google Gemini.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | body (Textarea, source language), body_es (Textarea), body_fr (Textarea), body_ja (Textarea), body_zh (Textarea) |
| File | site/ready.php (LazyCron) |
// site/ready.php — translate product descriptions into multiple languages
function translateProducts() {
$ai = wire('modules')->get('AiWire');
$products = wire('pages')->find("template=product, body!='', body_es='', limit=20");
$languages = [
'body_es' => 'Spanish',
'body_fr' => 'French',
'body_ja' => 'Japanese',
'body_zh' => 'Chinese (Simplified)',
];
foreach ($products as $product) {
foreach ($languages as $field => $langName) {
if ($product->$field) continue; // already translated
$ai->askAndSave($product, $field,
"Translate this product description to {$langName}. "
. "Keep all product names, brand names, and technical terms in English. "
. "Preserve the marketing tone. Return ONLY the translation.\n\n"
. $product->body,
[
'provider' => 'google',
'model' => 'gemini-flash-latest',
'maxTokens' => 2000,
'temperature' => 0.2,
]
);
}
}
}
// Run via LazyCron
$wire->addHook('LazyCron::everyHour', function() { translateProducts(); });
Result — translation fields populated on the product page:
body_es: "El Cristal 2015 de Louis Roederer es un champán de prestigio que representa
la cumbre del arte vinícola de la casa. Envejecido durante seis años sobre
sus lías, esta añada ofrece una complejidad extraordinaria..."
body_fr: "Le Cristal 2015 de Louis Roederer est une cuvée de prestige qui représente
le sommet de l'art vinicole de la maison..."
body_ja: "ルイ・ロデレール クリスタル 2015は、メゾンのワイン造りの芸術の頂点を
代表するプレステージ・キュヴェです..."
body_zh: "路易王妃水晶香槟2015年份是酒庄酿酒工艺的巅峰之作..."
LazyCron processes 20 products per hour — a 500-product catalog is fully translated in ~25 hours.
Problem: Customers browsing a spirits store don’t know what to buy — they need a knowledgeable advisor who knows your actual catalog. This chatbot searches your products, feeds context to the AI, and gives personalized recommendations with links to real product pages.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | api-sommelier (URL segment: /api/ai-sommelier/) |
| Dependencies | product template with title, body, tasting_notes, brand (Page ref), region (Page ref), abv (Float) |
| File | site/templates/api-sommelier.php |
| Frontend | AJAX POST with question parameter |
// site/templates/api/ai-sommelier.php
header('Content-Type: application/json');
$ai = $modules->get('AiWire');
$question = $input->post->text('question');
$history = $session->get('sommelier_history') ?: [];
if (!$question) {
echo json_encode(['error' => 'No question']);
return;
}
// Find relevant products
$clean = $sanitizer->selectorValue($question);
$products = $pages->find("template=product, title|body|tasting_notes%={$clean}, limit=8");
$context = "Available products:\n";
foreach ($products as $p) {
$context .= "- {$p->title} ({$p->parent->title}) — {$p->brand->title}, "
. "{$p->region->title}, {$p->abv}%, {$p->url}\n";
}
$result = $ai->askWithFallback($question, [
'provider' => 'anthropic',
'fallbackProviders' => ['openai', 'google'],
'systemPrompt' => "You are an expert sommelier and spirits advisor for LQRS, "
. "an online wine and spirits store. Help customers find the right drink. "
. "Always recommend specific products from our catalog when relevant. "
. "Include product URLs in your recommendations. "
. "If asked about cocktails, suggest recipes using our products. "
. "Be warm, knowledgeable, and never condescending. "
. "Reply in the customer's language.",
'maxTokens' => 600,
'temperature' => 0.6,
'history' => array_merge(
[['role' => 'user', 'content' => $context],
['role' => 'assistant', 'content' => "I've reviewed our catalog. How can I help you today?"]],
$history
),
]);
if ($result['success']) {
$history[] = ['role' => 'user', 'content' => $question];
$history[] = ['role' => 'assistant', 'content' => $result['content']];
if (count($history) > 20) $history = array_slice($history, -20);
$session->set('sommelier_history', $history);
}
echo json_encode([
'success' => $result['success'],
'reply' => $result['content'] ?? '',
'products' => $products->explode(['title', 'url', 'parent' => 'title']),
]);
Result — JSON API response for the frontend chat widget:
{
"success": true,
"reply": "For a smoky whiskey under $60, I'd recommend the Lagavulin 16 Year Old
(/spirits/whiskey/lagavulin-16/) — it's a classic Islay single malt with deep
peat smoke, maritime salt, and a long sweet finish. If you want something slightly
less intense, try the Talisker 10 Year Old (/spirits/whiskey/talisker-10/)
which balances smoke with peppery spice and honey.",
"products": [
{"title": "Lagavulin 16 Year Old", "url": "/spirits/whiskey/lagavulin-16/"},
{"title": "Talisker 10 Year Old", "url": "/spirits/whiskey/talisker-10/"}
]
}
Problem: Professional tasting notes (appearance, nose, palate, finish) require expert knowledge — most product pages ship without them. LazyCron finds products missing tasting notes and generates industry-standard descriptions every 6 hours.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | tasting_notes (Textarea), brand (Page ref), region (Page ref), abv (Float) |
| File | site/ready.php (LazyCron) |
// site/ready.php — generate tasting notes for products that don't have them
$wire->addHook('LazyCron::every6Hours', function() {
$ai = wire('modules')->get('AiWire');
$products = wire('pages')->find("template=product, tasting_notes='', limit=10");
foreach ($products as $p) {
$ai->askAndSave($p, 'tasting_notes',
"Write professional tasting notes for {$p->title}. "
. "Type: {$p->parent->title}. Brand: {$p->brand->title}. "
. "Region: {$p->region->title}. ABV: {$p->abv}%. "
. "Cover: appearance, nose (aroma), palate (taste), finish. "
. "Use industry-standard terminology. 3-4 sentences.",
[
'maxTokens' => 250,
'temperature' => 0.4,
'cache' => 'M',
]
);
}
});
Result — professional tasting notes saved to the product:
tasting_notes:
"Deep amber with golden highlights. The nose opens with rich caramel, dried apricot,
and a whisper of peat smoke over toasted oak. On the palate, layers of dark chocolate,
orange marmalade, and warm baking spices unfold with a velvety texture. The finish
is long and warming, with lingering notes of espresso and sea salt."
Problem: “What should I buy for my dad’s birthday under $80?” — your site can’t answer this with standard filtering. This API endpoint takes customer preferences, cross-references your catalog, and returns AI-picked recommendations with explanations.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | api-gift-finder (URL segment: /api/gift-finder/) |
| Dependencies | product template with title, price (Float), brand (Page ref) |
| File | site/templates/api-gift-finder.php |
| Frontend | AJAX POST with occasion, budget, taste, type parameters |
// site/templates/api/gift-finder.php
header('Content-Type: application/json');
$ai = $modules->get('AiWire');
$occasion = $input->post->text('occasion'); // birthday, anniversary, holiday...
$budget = $input->post->text('budget'); // 30-50, 50-100, 100+
$taste = $input->post->text('taste'); // sweet, dry, smoky, fruity...
$type = $input->post->text('type'); // wine, whiskey, any...
$selector = "template=product";
if ($type && $type !== 'any') $selector .= ", parent.name={$type}";
$catalog = $pages->find("{$selector}, limit=50");
$productList = '';
foreach ($catalog as $p) {
$productList .= "- {$p->title} | {$p->parent->title} | {$p->brand->title} | \${$p->price}\n";
}
$result = $ai->ask(
"A customer needs a gift recommendation.\n"
. "Occasion: {$occasion}\n"
. "Budget: \${$budget}\n"
. "Taste preference: {$taste}\n"
. "Category preference: {$type}\n\n"
. "Here are our available products:\n{$productList}\n\n"
. "Recommend 3 products from the list above. For each one explain "
. "why it's perfect for this occasion and taste. "
. "Reply as JSON array: [{\"product\": \"...\", \"reason\": \"...\"}]",
[
'maxTokens' => 500,
'temperature' => 0.6,
'cache' => 'D',
]
);
echo json_encode([
'success' => $result['success'],
'recommendations' => $result['success'] ? json_decode($result['content'], true) : [],
]);
Result — JSON API response for the gift finder widget:
{
"success": true,
"recommendations": [
{
"product": "Lagavulin 16 Year Old",
"reason": "A legendary Islay single malt — perfect for a father who appreciates
smoky, complex whiskey. The iconic square bottle makes an impressive gift."
},
{
"product": "Balvenie DoubleWood 12",
"reason": "Approachable yet sophisticated, aged in two types of cask. Great for
someone exploring single malts. Well within budget at $65."
},
{
"product": "Redbreast 12 Year Old",
"reason": "Ireland's finest pot still whiskey — smooth, fruity, and universally
loved. If your dad enjoys smooth sipping whiskey, this is a safe bet."
}
]
}
Problem: Your comparison page only shows raw specs — no context about flavor differences or value for money. AI reads product data and writes a natural-language comparison with specific recommendations for different preferences.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | compare (URL: /compare/?products=1042,1043,1044) |
| Dependencies | product template with title, brand (Page ref), region (Page ref), abv (Float), price (Float), tasting_notes (Textarea) |
| File | site/templates/compare.php |
// site/templates/compare.php
$ai = $modules->get('AiWire');
$ids = $input->get->intArray('products'); // ?products=1042,1043,1044
$products = $pages->getById($ids);
$productData = '';
foreach ($products as $p) {
$productData .= "### {$p->title}\n"
. "Category: {$p->parent->title}\n"
. "Brand: {$p->brand->title}\n"
. "Region: {$p->region->title}\n"
. "ABV: {$p->abv}%\n"
. "Price: \${$p->price}\n"
. "Tasting: {$p->tasting_notes}\n\n";
}
$result = $ai->ask(
"Compare these {$products->count()} products for a customer who wants to make an informed choice:\n\n"
. $productData
. "Write a comparison covering: flavor profiles, value for money, best occasions, "
. "and a clear recommendation for different preferences (e.g. 'If you prefer bold flavors, go with X').",
[
'maxTokens' => 800,
'temperature' => 0.5,
'cache' => 'W',
'pageId' => $products->first()->id,
]
);
Result — natural-language comparison rendered on the page:
"All three are premium single malts, but they offer very different experiences.
The Lagavulin 16 is the boldest of the three — heavy peat smoke, maritime salt,
and a long, warming finish. It's an Islay classic that demands attention.
The Balvenie DoubleWood 12 sits at the opposite end — smooth, honeyed, and
approachable, with vanilla and dried fruit from its dual-cask aging. It's the
best entry point for single malt beginners.
The Talisker 10 bridges the gap — moderate smoke with a peppery kick and coastal
character that's complex without being overwhelming.
If you prefer bold, smoky flavors: Lagavulin 16.
If you want smooth and easy-drinking: Balvenie DoubleWood 12.
If you want the best of both worlds: Talisker 10.
Best value for money: Balvenie DoubleWood 12 at $65."
Problem: Products need filter tags (smoky, premium, gift-worthy, after-dinner…) for faceted search, but nobody tags 500+ products manually. AI analyzes each product and assigns relevant tags, creating new tag pages if they don’t exist.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product with ai_tags (Page ref multiple → tag) |
| Template | tag (under /tags/ parent) with title (Text) |
| File | site/ready.php |
// site/ready.php — assign tags to products based on AI analysis
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'product') return;
if ($page->ai_tags->count()) return; // already tagged
$ai = $this->modules->get('AiWire');
$result = $ai->ask(
"Analyze this product and assign relevant tags for filtering in an online store.\n\n"
. "Product: {$page->title}\n"
. "Type: {$page->parent->title}\n"
. "Description: " . mb_substr(strip_tags($page->body), 0, 1000) . "\n\n"
. "Return a JSON array of 5-10 tags. Use lowercase. "
. "Include: flavor profile, occasion, gift suitability, price tier, style.\n"
. "Example: [\"smoky\",\"premium\",\"gift-worthy\",\"after-dinner\",\"aged\"]",
[
'maxTokens' => 100,
'temperature' => 0.2,
'provider' => 'openai',
'model' => 'gpt-5-nano',
]
);
if ($result['success']) {
$tags = json_decode($result['content'], true);
if (is_array($tags)) {
foreach ($tags as $tagName) {
$tag = wire('pages')->get("template=tag, name=" . wire('sanitizer')->pageName($tagName));
if (!$tag->id) {
$tag = new Page();
$tag->template = 'tag';
$tag->parent = wire('pages')->get('/tags/');
$tag->title = ucfirst($tagName);
$tag->name = wire('sanitizer')->pageName($tagName);
$tag->save();
}
$page->ai_tags->add($tag);
}
$page->save('ai_tags', ['quiet' => true]);
}
}
});
Result — tags automatically created and assigned to the product:
Page "Lagavulin 16 Year Old" now has ai_tags:
→ Smoky (created: /tags/smoky/)
→ Premium (created: /tags/premium/)
→ Gift-worthy (created: /tags/gift-worthy/)
→ After-dinner (created: /tags/after-dinner/)
→ Aged (created: /tags/aged/)
→ Peaty (created: /tags/peaty/)
→ Islay (created: /tags/islay/)
→ Full-bodied (created: /tags/full-bodied/)
Tags that already exist are reused; new ones are created under /tags/. Products become instantly filterable in your catalog.
Problem: Sending a weekly email digest requires someone to write an engaging intro about new arrivals and trending products — every single week. AI writes the newsletter body based on actual new products and view statistics from your catalog.
ProcessWire setup:
| Item | Details |
|---|---|
| Dependencies | product template with views (Integer) and brand (Page ref), wireMail() configured |
| File | site/ready.php (LazyCron) |
// site/ready.php — weekly digest email with AI-generated summary
$wire->addHook('LazyCron::everyWeek', function() {
$ai = wire('modules')->get('AiWire');
$since = date('Y-m-d', strtotime('-7 days'));
$newProducts = wire('pages')->find("template=product, created>={$since}");
$topViewed = wire('pages')->find("template=product, sort=-views, limit=5");
$productList = $newProducts->implode("\n", function($p) {
return "- {$p->title} ({$p->parent->title}) by {$p->brand->title}";
});
$topList = $topViewed->implode("\n", function($p) {
return "- {$p->title} ({$p->views} views)";
});
$summary = $ai->chat(
"Write a friendly weekly newsletter intro for an online spirits store.\n\n"
. "New arrivals this week ({$newProducts->count()} products):\n{$productList}\n\n"
. "Most popular products:\n{$topList}\n\n"
. "Write 2-3 engaging paragraphs. Highlight interesting new arrivals "
. "and mention trending products. Keep it under 200 words.",
['maxTokens' => 400, 'temperature' => 0.7]
);
if ($summary) {
$mail = wireMail();
$mail->to('subscribers@lqrs.com');
$mail->subject("🥂 LQRS Weekly: {$newProducts->count()} New Arrivals");
$mail->body($summary);
$mail->send();
}
});
Result — subscribers receive an email:
Subject: LQRS Weekly: 12 New Arrivals
Body:
"This week we've welcomed 12 exciting new additions to our shelves, and there's
something for every palate. Whiskey lovers will be thrilled by the arrival of the
Redbreast 15 Year Old and the limited-edition Lagavulin Feis Ile 2024 — both
are exceptional expressions that won't last long.
On the wine front, we've added a stunning Barolo from Aldo Conterno and a crisp
Sancerre from Jean Reverdy that's already turning heads among our staff.
Meanwhile, the Buffalo Trace continues its reign as our most-viewed product this
week with over 2,300 page views, followed closely by the perennial favorite
Lagavulin 16. Cheers to a great week of discoveries!"
Problem: A simple Q&A endpoint forgets previous messages — users can’t have a natural conversation with follow-up questions. Session-based history keeps the last 20 messages, letting the AI reference earlier context for coherent multi-turn dialogue.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | api-chatbot (URL segment: /api/chatbot/) |
| File | site/templates/api-chatbot.php |
| Frontend | AJAX POST with message and optional action=reset |
// site/templates/api/chatbot.php
header('Content-Type: application/json');
$ai = $modules->get('AiWire');
$message = $input->post->text('message');
$action = $input->post->text('action');
if ($action === 'reset' || !$session->get('chat_history')) {
$session->set('chat_history', []);
}
if (!$message) {
echo json_encode(['error' => 'No message']);
return;
}
$history = $session->get('chat_history');
$result = $ai->askWithFallback($message, [
'provider' => 'anthropic',
'fallbackProviders' => ['openai', 'google'],
'systemPrompt' => 'You are a helpful assistant for LQRS, an online wine and spirits store. '
. 'Help customers find products, suggest pairings, and answer questions. '
. 'Be concise and reply in the user\'s language.',
'maxTokens' => 500,
'temperature' => 0.6,
'history' => $history,
]);
if ($result['success']) {
$history[] = ['role' => 'user', 'content' => $message];
$history[] = ['role' => 'assistant', 'content' => $result['content']];
if (count($history) > 20) $history = array_slice($history, -20);
$session->set('chat_history', $history);
}
echo json_encode([
'success' => $result['success'],
'reply' => $result['content'] ?? '',
]);
Result — multi-turn conversation via JSON API:
// Turn 1: User asks
{"message": "I like smoky whiskey, what do you have?"}
→ {"success": true, "reply": "We have several great smoky options! The Lagavulin 16..."}
// Turn 2: User follows up (AI remembers context)
{"message": "Which one is best for under $70?"}
→ {"success": true, "reply": "For under $70, I'd go with the Talisker 10 at $55..."}
// Turn 3: User changes topic (AI still has full history)
{"message": "Do you have any good red wines too?"}
→ {"success": true, "reply": "Absolutely! If you enjoy bold, smoky flavors in whiskey,
you might appreciate a full-bodied red like the Aldo Conterno Barolo..."}
Problem: Not sure which AI provider writes the best product descriptions for your niche? Test them all side-by-side before committing.
askMultiple()sends the same prompt to all providers and shows results in a comparison table.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | any (admin-only or debug page) |
| File | any template file or Tracy Debugger console |
$ai = $modules->get('AiWire');
$prompt = 'Describe the flavor profile of a 12-year-old single malt Scotch in 3 sentences.';
$results = $ai->askMultiple($prompt, ['anthropic', 'openai', 'google', 'xai']);
echo "<table><tr><th>Provider</th><th>Response</th><th>Tokens</th></tr>";
foreach ($results as $provider => $result) {
$content = $result['success'] ? htmlspecialchars($result['content']) : 'Error: ' . $result['message'];
$tokens = $result['usage']['total_tokens'] ?? '—';
echo "<tr><td>{$provider}</td><td>{$content}</td><td>{$tokens}</td></tr>";
}
echo "</table>";
Result — comparison table showing each provider’s response:
| Provider | Response | Tokens |
|-----------|---------------------------------------------------|--------|
| anthropic | "A 12-year-old single malt Scotch typically | 142 |
| | reveals layers of honey, vanilla, and dried..." | |
| openai | "The flavor profile opens with warm caramel | 156 |
| | and orchard fruit, transitioning to gentle..." | |
| google | "Expect a harmonious balance of sweet malt, | 138 |
| | subtle oak spice, and a whisper of smoke..." | |
| xai | "Rich and complex, this whisky delivers notes | 151 |
| | of toffee, cinnamon, and toasted almond..." | |
Problem: You just imported 500 products but most are missing descriptions, tasting notes, and category text. Generating it all at once would overwhelm the API. LazyCron processes a small batch every hour — products, brands, and categories — filling gaps gradually without hitting rate limits.
ProcessWire setup:
| Item | Details |
|---|---|
| Templates | product, brand, category |
| Fields | tasting_notes (Textarea), abv (Float) on product; ai_brand_history (Textarea), country (Page ref) on brand; ai_description (Textarea) on category |
| File | site/ready.php (LazyCron) |
// site/ready.php — generate missing AI content across the entire catalog
$wire->addHook('LazyCron::everyHour', function() {
$ai = wire('modules')->get('AiWire');
// Products without tasting notes
$products = wire('pages')->find("template=product, tasting_notes='', limit=10");
foreach ($products as $p) {
$ai->askAndSave($p, 'tasting_notes',
"Write professional tasting notes for {$p->title}. "
. "Type: {$p->parent->title}. ABV: {$p->abv}%. "
. "Cover: appearance, nose, palate, finish. 3-4 sentences.",
['maxTokens' => 250, 'temperature' => 0.4]
);
}
// Brands without history
$brands = wire('pages')->find("template=brand, ai_brand_history='', limit=5");
foreach ($brands as $b) {
$ai->askAndSave($b, 'ai_brand_history',
"Write a 2-paragraph history of {$b->title} (alcohol brand). "
. "Country: {$b->country->title}.",
['maxTokens' => 400, 'temperature' => 0.5]
);
}
// Categories without descriptions
$cats = wire('pages')->find("template=category, ai_description='', limit=5");
foreach ($cats as $c) {
$count = $c->children->count();
$ai->askAndSave($c, 'ai_description',
"Write a 2-paragraph description for the '{$c->title}' category page "
. "of an online spirits store. We carry {$count} products.",
['maxTokens' => 300, 'temperature' => 0.6]
);
}
});
Result — LazyCron log after one hour:
[AiWire] askAndSave: saved tasting notes for "Voltage Vodka" (page 1042)
[AiWire] askAndSave: saved tasting notes for "La Fabrique 70% Vodka" (page 1043)
[AiWire] askAndSave: saved tasting notes for "Humble Banane Banana Liqueur" (page 1044)
... (10 products processed)
[AiWire] askAndSave: saved brand history for "Chivas Regal" (page 2001)
[AiWire] askAndSave: saved brand history for "Mauro Vannucci" (page 2002)
... (5 brands processed)
[AiWire] askAndSave: saved category description for "Vodka" (page 3001)
[AiWire] askAndSave: saved category description for "Liqueurs & Cordials" (page 3002)
... (5 categories processed)
After 24 hours: ~240 products, ~120 brands, ~120 categories filled automatically.
Problem: Product images need descriptive alt text for SEO and accessibility, but editors upload images without filling in the description. This hook generates alt text for every image that’s missing one, using the cheapest/fastest model since the task is simple.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | images (Images field, uses built-in description property) |
| File | site/ready.php |
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'product') return;
if (!$page->hasField('images')) return;
$ai = $this->modules->get('AiWire');
foreach ($page->images as $image) {
if ($image->description) continue;
$altText = $ai->chat(
"Generate a descriptive alt text (max 125 chars) for a product image. "
. "Product: {$page->title}\n"
. "Category: {$page->parent->title}\n"
. "Filename: {$image->basename}\n"
. "Return ONLY the alt text.",
[
'maxTokens' => 50,
'temperature' => 0.3,
'provider' => 'google',
'model' => 'gemini-flash-lite-latest',
]
);
if ($altText) {
$image->description = mb_substr($altText, 0, 125);
}
}
$page->save('images', ['quiet' => true]);
});
Result — image descriptions saved to the Images field:
images[0]->description = "Bottle of Lagavulin 16 Year Old single malt Scotch whisky
with distinctive white label on dark green glass"
images[1]->description = "Close-up of Lagavulin 16 whisky poured in a Glencairn glass
showing deep amber color"
images[2]->description = "Lagavulin distillery on the shore of Lagavulin Bay, Islay,
Scotland with white buildings and pagoda roofs"
Alt texts appear in <img alt="..."> tags — improving SEO and accessibility scores.
Problem: Contact form submissions pile up in one inbox — sales inquiries, support tickets, and wholesale requests all go to the same person. AI classifies each message and routes it to the right department email with a priority level.
ProcessWire setup:
| Item | Details |
|---|---|
| Dependencies | FormBuilder module (or any form handler), wireMail() configured |
| File | site/ready.php |
$wire->addHookAfter('FormBuilder::processReady', function(HookEvent $event) {
$form = $event->arguments(0);
$ai = wire('modules')->get('AiWire');
$message = $form->get('message')->value;
$email = $form->get('email')->value;
$result = $ai->ask(
"Analyze this customer inquiry for a wine and spirits store.\n"
. "Reply ONLY with JSON:\n"
. '{"department":"sales|support|wholesale|general",'
. '"priority":"low|medium|high",'
. '"summary":"one-sentence summary"}'
. "\n\nMessage: {$message}",
[
'maxTokens' => 100,
'temperature' => 0,
'provider' => 'openai',
'model' => 'gpt-5-nano',
]
);
if ($result['success']) {
$analysis = json_decode($result['content'], true);
if ($analysis) {
$emails = [
'sales' => 'sales@lqrs.com',
'support' => 'support@lqrs.com',
'wholesale' => 'wholesale@lqrs.com',
'general' => 'info@lqrs.com',
];
$to = $emails[$analysis['department']] ?? $emails['general'];
$mail = wireMail();
$mail->to($to);
$mail->subject("[{$analysis['priority']}] {$analysis['summary']}");
$mail->body("From: {$email}\n\n{$message}");
$mail->send();
}
}
});
Result — email routed to the correct department:
// Customer message: "I run a restaurant chain and want to discuss bulk pricing
// for your whiskey selection. We'd need 50+ cases monthly."
//
// AI analysis: {"department": "wholesale", "priority": "high",
// "summary": "Restaurant chain bulk whiskey inquiry, 50+ cases/month"}
//
// → Email sent to: wholesale@lqrs.com
// → Subject: [high] Restaurant chain bulk whiskey inquiry, 50+ cases/month
// → Body: From: john@restaurant.com
//
// I run a restaurant chain and want to discuss bulk pricing...
Sales inquiries go to sales@, support tickets go to support@, wholesale requests go to wholesale@ — automatically, within seconds.
Problem: Using one premium AI model for everything is expensive. Simple tasks (tagging, classification) don’t need GPT-5 — but complex tasks (detailed overviews, creative writing) do. This pipeline routes each task to the cheapest model that can handle it, cutting API costs by 60-80%.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | all AI fields from previous examples |
| File | site/templates/product.php |
| Keys needed | At least one key for Anthropic, OpenAI, and Google |
// site/templates/product.php — cost-optimized content generation
$ai = $modules->get('AiWire');
$results = $ai->generate($page, [
// TIER 1: Complex creative writing → premium model
[
'field' => 'ai_overview',
'prompt' => "Write a detailed, engaging overview of {$page->title}...",
'options' => [
'provider' => 'anthropic',
'model' => 'claude-sonnet-4-5-20250929',
'maxTokens' => 600,
'temperature' => 0.7,
'timeout' => 30,
],
],
// TIER 2: Structured content → mid-range model
[
'field' => 'ai_food_pairing',
'prompt' => "Suggest 5 food pairings for {$page->title}...",
'options' => [
'provider' => 'openai',
'model' => 'gpt-4.1-mini',
'maxTokens' => 400,
'temperature' => 0.5,
],
],
// TIER 3: Simple extraction → cheapest/fastest model
[
'field' => 'ai_tags_text',
'prompt' => "Return 5-8 comma-separated tags for {$page->title}: {$page->parent->title}",
'options' => [
'provider' => 'google',
'model' => 'gemini-flash-lite-latest',
'maxTokens' => 50,
'temperature' => 0,
],
],
// TIER 3: Translation → cheap model with high token limit
[
'field' => 'ai_description_es',
'prompt' => "Translate to Spanish:\n{$page->body}",
'options' => [
'provider' => 'google',
'model' => 'gemini-flash-latest',
'maxTokens' => 2000,
'temperature' => 0.1,
],
],
// OpenRouter: use DeepSeek for budget-friendly long content
[
'field' => 'ai_history',
'prompt' => "Write the production history of {$page->title}...",
'options' => [
'provider' => 'openrouter',
'model' => 'deepseek/deepseek-v3.2',
'maxTokens' => 800,
],
],
], [
'cache' => 'M',
]);
Result — each block generated by a different provider at different cost:
ai_overview → Anthropic Claude Sonnet 4.5 ($$$) — 600 tokens, best quality
ai_food_pairing → OpenAI GPT-4.1 Mini ($$) — 400 tokens, good structured output
ai_tags_text → Google Gemini Flash Lite ($) — 50 tokens, instant classification
ai_description_es → Google Gemini Flash ($) — 2000 tokens, solid translation
ai_history → OpenRouter DeepSeek V3.2 ($) — 800 tokens, budget creative writing
Estimated cost per product page: ~$0.003 vs ~$0.015 with premium model only.
Problem: Your primary Anthropic key hits rate limits during peak hours; the site shows “AI content unavailable” to customers.
askWithFallback()tries all keys for each provider, then falls through to backup providers — zero downtime even during API outages.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | ai_overview (Textarea) |
| File | site/templates/product.php |
| Admin | Multiple keys per provider: Anthropic (2 keys), OpenAI (1 key), Google (1 key) |
// site/templates/product.php — bulletproof AI content delivery
$ai = $modules->get('AiWire');
// Check if content already exists
$existing = $ai->loadFrom($page, 'ai_overview');
if ($existing) {
echo $existing;
return;
}
// Fallback chain: Anthropic key #0 → key #1 → OpenAI → Google
$result = $ai->askWithFallback(
"Write an overview of {$page->title}...",
[
'provider' => 'anthropic',
'fallbackProviders' => ['openai', 'google'],
'maxTokens' => 500,
'temperature' => 0.6,
'timeout' => 15,
'cache' => 'W',
]
);
if ($result['success']) {
// Save to field for future requests
$ai->saveTo($page, 'ai_overview', $result);
echo $result['content'];
// Log which provider/key actually answered
wire('log')->save('ai-provider-usage',
"Page {$page->id}: provider={$result['usedProvider']}, "
. "key={$result['usedKeyLabel']} (#{$result['usedKeyIndex']}), "
. "tokens={$result['usage']['total_tokens']}"
);
} else {
echo "<p class='ai-fallback'>Content being prepared...</p>";
}
Result — logged provider rotation during peak traffic:
[ai-provider-usage] Page 1042: provider=anthropic, key=Production key (#0), tokens=312
[ai-provider-usage] Page 1043: provider=anthropic, key=Production key (#0), tokens=287
[ai-provider-usage] Page 1044: provider=anthropic, key=Backup key (#1), tokens=295 ← rate limited, switched to key #1
[ai-provider-usage] Page 1045: provider=openai, key=Main key (#0), tokens=310 ← both Anthropic keys exhausted
[ai-provider-usage] Page 1046: provider=anthropic, key=Production key (#0), tokens=303 ← back to primary
Problem: You need granular control — check which providers are configured, test connections, get provider instances for custom integrations, and monitor API key health from your admin dashboard.
getProvider()andgetProvidersStatus()give you direct access to the provider layer.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | admin-ai-dashboard (admin page) |
| File | site/templates/admin-ai-dashboard.php |
// site/templates/admin-ai-dashboard.php — AI provider health dashboard
$ai = $modules->get('AiWire');
// ── 1. Check all providers status ──
$status = $ai->getProvidersStatus();
echo "<h2>Provider Status</h2><table>";
echo "<tr><th>Provider</th><th>Keys</th><th>Active</th><th>Default Key</th></tr>";
foreach ($status as $name => $info) {
$total = $info['totalKeys'];
$active = $info['activeKeys'];
$default = $info['defaultKeyIndex'] !== null ? "#{$info['defaultKeyIndex']}" : 'Auto';
$color = $active > 0 ? 'green' : 'red';
echo "<tr><td>{$name}</td><td>{$total}</td>"
. "<td style='color:{$color}'>{$active}</td><td>{$default}</td></tr>";
}
echo "</table>";
// ── 2. Get a specific provider instance for custom use ──
$anthropic = $ai->getProvider('anthropic');
if ($anthropic) {
echo "<p>Anthropic provider ready: {$anthropic->getProviderKey()}</p>";
}
// Use a specific key by index (e.g., dedicated key for admin tasks)
$adminProvider = $ai->getProvider('openai', null, 2); // third OpenAI key
if ($adminProvider) {
$testResult = $ai->ask('Say "OK"', [
'provider' => 'openai',
'keyIndex' => 2,
'maxTokens' => 5,
]);
echo $testResult['success'] ? "<p>Admin key OK</p>" : "<p>Admin key FAILED</p>";
}
// ── 3. Cache statistics ──
$cacheStats = $ai->cacheStats();
echo "<h2>Cache</h2>";
echo "<p>Files: {$cacheStats['files']}, Size: " . round($cacheStats['size'] / 1024) . " KB</p>";
// ── 4. Clear cache for a specific product (e.g., after content update) ──
$product = $pages->get(1042);
$cleared = $ai->clearCache($product);
echo "<p>Cleared {$cleared} cache entries for '{$product->title}'</p>";
// ── 5. Clear all cache (after major content migration) ──
// $totalCleared = $ai->clearAllCache();
// echo "<p>Cleared {$totalCleared} total cache entries</p>";
Result — admin dashboard output:
Provider Status
| Provider | Keys | Active | Default Key |
|------------|------|--------|-------------|
| anthropic | 2 | 2 | #0 |
| openai | 3 | 2 | Auto |
| google | 1 | 1 | Auto |
| xai | 1 | 1 | Auto |
| openrouter | 1 | 0 | Auto | ← key disabled in admin
Anthropic provider ready: anthropic
Admin key OK
Cache
Files: 847, Size: 2,340 KB
Cleared 4 cache entries for 'Lagavulin 16 Year Old'
Problem: Cached AI content should be page-specific (different product = different cache entry), but some prompts are reusable across pages. You also need to invalidate cache when editors update content. AiWire’s cache supports page-scoped keys, TTL levels, and hook-based invalidation.
ProcessWire setup:
| Item | Details |
|---|---|
| Template | product |
| Fields | body (Textarea), ai_overview (Textarea) |
| File | site/templates/product.php + site/ready.php |
// site/templates/product.php — cache strategies
$ai = $modules->get('AiWire');
// ── Page-scoped cache: same prompt returns different results per product ──
$overview = $ai->chat(
"Write an overview of {$page->title}...",
[
'cache' => 'M', // cache for 1 month
'pageId' => $page->id, // scoped to this product (different product = different cache)
]
);
// ── Global cache: same result for all pages (e.g., store-wide content) ──
$storeFacts = $ai->chat(
"Write 3 fun facts about wine collecting",
[
'cache' => 'W', // no pageId = global cache, shared across all pages
]
);
// ── No cache: always fresh (e.g., daily recommendations) ──
$dailyPick = $ai->chat(
"Pick one product from this list and explain why it's today's recommendation:\n"
. $pages->find("template=product, sort=random, limit=5")->implode("\n", 'title'),
[
'cache' => false, // never cache, always fresh
]
);
// ── TTL options: D (day), W (week), M (month), Y (year) ──
$seasonal = $ai->chat("Write autumn cocktail suggestions...", ['cache' => 'W']);
$history = $ai->chat("Write the history of whiskey...", ['cache' => 'Y']); // rarely changes
// ── Direct cache access for advanced use ──
$cache = $ai->cache();
$stats = $cache->stats();
echo "Cache: {$stats['files']} entries, " . round($stats['size'] / 1024) . " KB";
// site/ready.php — auto-clear cache when product is updated by editor
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'product') return;
if (!$page->isChanged('body') && !$page->isChanged('tasting_notes')) return;
$ai = $this->modules->get('AiWire');
// Clear only this product's cached AI content
$cleared = $ai->clearCache($page);
if ($cleared > 0) {
$this->message("Cleared {$cleared} AI cache entries for '{$page->title}'");
}
// Also regenerate AI fields with fresh data
$ai->generate($page, [
['field' => 'ai_overview', 'prompt' => "Write overview of {$page->title}...", 'overwrite' => true],
['field' => 'ai_food_pairing','prompt' => "Suggest pairings for {$page->title}...", 'overwrite' => true],
], ['temperature' => 0.6]);
});
Result — cache behavior:
# First visit to "Lagavulin 16" product page:
ai_overview → MISS → API call (312 tokens, 1.2s) → cached for 1 month
store_facts → MISS → API call (95 tokens, 0.6s) → cached for 1 week (global)
daily_pick → API call (always fresh, no cache)
# Second visit to same page:
ai_overview → HIT → from cache (0ms, 0 tokens, $0)
store_facts → HIT → from cache (0ms, 0 tokens, $0)
daily_pick → API call (always fresh)
# Visit to "Balvenie 12" product page:
ai_overview → MISS → API call (different product, different cache key)
store_facts → HIT → global cache, same content for all pages
daily_pick → API call (always fresh)
# Editor updates "Lagavulin 16" body text and saves:
→ "Cleared 4 AI cache entries for 'Lagavulin 16 Year Old'"
→ ai_overview and ai_food_pairing regenerated with new data
Problem: Different teams (marketing, SEO, dev) share the same AiWire module but need separate API keys for billing, rate limits, and access control.
keyIndexlets you assign specific keys to specific tasks, and key labels in the admin make it clear which key is which.
ProcessWire setup:
| Item | Details |
|---|---|
| Admin | Anthropic keys: #0 “Production” (#1 “Marketing team”, #2 “SEO batch jobs”) |
| File | site/ready.php + templates |
$ai = $modules->get('AiWire');
// ── Marketing team: use key #1 for promotional content ──
$promo = $ai->ask(
"Write a promotional banner text for {$page->title}...",
[
'provider' => 'anthropic',
'keyIndex' => 1, // "Marketing team" key — billed separately
'maxTokens' => 200,
]
);
// ── SEO batch jobs: use key #2 with higher rate limits ──
$products = $pages->find("template=product, seo_description='', limit=50");
foreach ($products as $p) {
$ai->askAndSave($p, 'seo_description',
"Write SEO description for {$p->title}...",
[
'provider' => 'anthropic',
'keyIndex' => 2, // "SEO batch jobs" key — dedicated quota
'maxTokens' => 100,
'temperature' => 0.3,
]
);
}
// ── Frontend chatbot: use default key (key #0, "Production") ──
$result = $ai->askWithFallback($userMessage, [
'provider' => 'anthropic', // uses key #0 by default
'fallbackProviders' => ['openai'],
'maxTokens' => 500,
]);
// ── Check which key was used ──
if ($result['success']) {
wire('log')->save('ai-keys', sprintf(
"Task: chatbot | Provider: %s | Key: %s (#%d) | Tokens: %d",
$result['usedProvider'],
$result['usedKeyLabel'],
$result['usedKeyIndex'],
$result['usage']['total_tokens'] ?? 0
));
}
Result — API key usage tracked per team:
[ai-keys] Task: promo | Provider: anthropic | Key: Marketing team (#1) | Tokens: 156
[ai-keys] Task: seo-batch | Provider: anthropic | Key: SEO batch jobs (#2) | Tokens: 89
[ai-keys] Task: seo-batch | Provider: anthropic | Key: SEO batch jobs (#2) | Tokens: 94
[ai-keys] Task: chatbot | Provider: anthropic | Key: Production (#0) | Tokens: 312
[ai-keys] Task: chatbot | Provider: openai | Key: Main key (#0) | Tokens: 298 ← fallback
Monthly billing breakdown:
Key #0 "Production": 23,400 tokens ($0.47)
Key #1 "Marketing team": 8,200 tokens ($0.16)
Key #2 "SEO batch jobs": 145,000 tokens ($2.90)
Add multiple API keys per provider in the admin panel. This enables load distribution, quota management, and automatic failover.
$ai = $modules->get('AiWire');
// Keys are 0-indexed in the order they appear in admin
$result = $ai->ask('Hello', [
'provider' => 'anthropic',
'keyIndex' => 1, // second key
]);
$ai = $modules->get('AiWire');
// Tries all Anthropic keys → all OpenAI keys → all Google keys
$result = $ai->askWithFallback('Summarize this document...', [
'provider' => 'anthropic',
'fallbackProviders' => ['openai', 'google'],
]);
if ($result['success']) {
echo $result['content'];
echo "Answered by: {$result['usedProvider']}"; // e.g. 'openai'
echo "Key index: {$result['usedKeyIndex']}"; // e.g. 0
echo "Key label: {$result['usedKeyLabel']}"; // e.g. 'Production key'
}
The configuration page (Modules → Configure → AiWire) includes:
AiWire includes a file-based cache that stores AI responses to avoid repeated API calls. This is essential for page rendering — without cache, every page load would wait for an AI response.
Cache files are stored in site/assets/cache/AiWire/ organized by page ID:
site/assets/cache/AiWire/
├── 0/ # global (no page context)
│ └── a1b2c3.json
├── 1042/ # page ID 1042
│ └── f7a8b9.json
└── 1085/ # page ID 1085
└── c0d1e2.json
| Value | Duration |
|---|---|
'D' |
1 day |
'W' |
1 week |
'M' |
1 month (30 days) |
'Y' |
1 year |
'2D' |
2 days |
'3W' |
3 weeks |
'6M' |
6 months |
3600 |
3600 seconds (1 hour) |
$ai = $modules->get('AiWire');
// Cache for 1 week — identical request returns instantly from cache
$result = $ai->ask('Write a tagline for our bakery', [
'cache' => 'W',
]);
echo $result['content']; // AI response
echo $result['cached']; // true if served from cache, false if fresh
When used in page templates, pass pageId so each page gets its own cache:
// site/templates/article.php
$ai = $modules->get('AiWire');
$summary = $ai->ask("Summarize this article in 2 sentences:\n\n" . $page->body, [
'cache' => 'M', // cache for 1 month
'pageId' => $page, // or $page->id — both work
'maxTokens' => 200,
'temperature' => 0.3,
]);
if ($summary['success']) {
echo "<div class='ai-summary'>{$summary['content']}</div>";
}
First visit: AI processes the text (~2-3 seconds). Every subsequent visit for the next month: instant from cache.
$meta = $ai->chat("Write SEO meta description for: {$page->title}", [
'cache' => 'W',
'pageId' => $page,
]);
// site/ready.php
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if (!$page->isChanged('body')) return;
$ai = $this->modules->get('AiWire');
$cleared = $ai->clearCache($page);
if ($cleared) {
$this->message("Cleared {$cleared} AI cache files for this page");
}
});
// site/templates/_main.php
$ai = $modules->get('AiWire');
$suggestions = $ai->ask(
"Suggest 3 related topics for this page. Reply as JSON array of strings.\n\n"
. "Title: {$page->title}\n"
. "Content: " . mb_substr(strip_tags($page->body), 0, 1000),
[
'cache' => 'W',
'pageId' => $page,
'maxTokens' => 150,
'temperature' => 0.5,
'provider' => 'google',
'model' => 'gemini-flash-lite-latest',
]
);
if ($suggestions['success']) {
$topics = json_decode($suggestions['content'], true);
if ($topics) {
echo "<aside><h4>You might also like</h4><ul>";
foreach ($topics as $topic) {
echo "<li>" . htmlspecialchars($topic) . "</li>";
}
echo "</ul></aside>";
}
}
$ai = $modules->get('AiWire');
$ai->clearCache($page); // clear cache for one page
$ai->clearAllCache(); // clear everything
$stats = $ai->cacheStats();
// ['total_files' => 42, 'total_size' => 128400, 'pages' => 12, 'expired' => 3]
Expired cache files are cleaned up automatically once per day via ProcessWire’s LazyCron. You can also clear all cache from admin at Modules → Configure → AiWire → Cache.
AiWire can save AI responses directly into page fields. Unlike cache (temporary files), field storage is permanent — content survives cache expiry, is editable by users in the admin, and is searchable via PW selectors.
Cache vs Field: use cache for repeated runtime calls (rendering). Use field storage when AI content becomes part of the page data (SEO descriptions, summaries, translations).
saveTo(Page $page, string $fieldName, string|array $content, bool $quiet = true): boolSave content to a page field. Accepts a string or a full ask() result array.
$ai = $modules->get('AiWire');
// Save a string
$ai->saveTo($page, 'ai_summary', 'This is a great article about cats.');
// Save directly from ask() result
$result = $ai->ask("Summarize: {$page->body}");
$ai->saveTo($page, 'ai_summary', $result);
loadFrom(Page $page, string $fieldName): ?stringLoad content from a page field. Returns null if the field is empty.
$summary = $ai->loadFrom($page, 'ai_summary');
if ($summary) {
echo $summary; // already generated
}
askAndSave(Page $page, string $fieldName, string $message, array $options = []): arrayThe main convenience method. Checks the field first — if content exists, returns it instantly. If empty, calls AI, saves the result to the field, and returns it.
$ai = $modules->get('AiWire');
$result = $ai->askAndSave($page, 'ai_summary',
"Write a 2-sentence summary of this article:\n\n" . $page->body,
[
'maxTokens' => 200,
'temperature' => 0.3,
]
);
echo $result['content']; // AI-generated or from field
echo $result['source']; // 'field' or 'ai'
Extra options:
| Option | Type | Default | Description |
|---|---|---|---|
overwrite |
bool | false | If true, always call AI even if the field has content |
quiet |
bool | true | Save without triggering PW hooks |
// site/ready.php
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'article') return;
if (!$page->isChanged('body')) return;
$ai = $this->modules->get('AiWire');
// Always regenerate when body changes
$ai->askAndSave($page, 'seo_description',
"Write an SEO meta description (max 160 chars) for:\n\n{$page->title}\n{$page->body}",
[
'overwrite' => true, // body changed, regenerate
'maxTokens' => 100,
'temperature' => 0.3,
]
);
});
// One AI call, result saved to both fields
$ai->askAndSave($page, ['seo_description', 'og_description'],
"Write a compelling description (max 160 chars) for:\n{$page->title}",
['maxTokens' => 100]
);
// site/ready.php — generate all AI content for a page at once
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'article') return;
if (!$page->isChanged('body')) return;
$ai = $this->modules->get('AiWire');
$body = mb_substr(strip_tags($page->body), 0, 2000);
$results = $ai->askAndSave($page, [
'seo_description' => "Write SEO meta description (max 160 chars) for:\n{$page->title}\n{$body}",
'ai_summary' => "Summarize this article in 3 sentences:\n{$body}",
'ai_keywords' => "Extract 5 SEO keywords as comma-separated list:\n{$body}",
], null, [
'overwrite' => true,
'maxTokens' => 200,
'temperature' => 0.3,
]);
// $results['seo_description']['source'] => 'ai'
// $results['ai_summary']['source'] => 'ai'
// $results['ai_keywords']['source'] => 'ai'
});
// site/templates/article.php
$ai = $modules->get('AiWire');
// First visit: AI generates and saves. Every visit after: instant from field.
$result = $ai->askAndSave($page, 'ai_summary',
"Summarize in 3 sentences:\n\n" . $page->body,
['maxTokens' => 200, 'cache' => 'W'] // cache also active as double layer
);
echo "<div class='summary'>{$result['content']}</div>";
// site/ready.php
$wire->addHook('LazyCron::everyHour', function() {
$ai = wire('modules')->get('AiWire');
$pages = wire('pages')->find("template=product, ai_description=''");
foreach ($pages as $p) {
$ai->askAndSave($p, [
'ai_description' => "Write a product description for: {$p->title}\nFeatures: {$p->features}",
'ai_keywords' => "Extract 5 keywords for: {$p->title}",
], null, ['maxTokens' => 300, 'temperature' => 0.7]);
}
});
generate() — product page with multiple AI blocksFor pages where each AI block needs its own prompt, provider, or settings:
// site/templates/product.php — e.g. "2015 Louis Roederer Cristal"
$ai = $modules->get('AiWire');
$results = $ai->generate($page, [
[
'field' => 'ai_overview',
'prompt' => "Write a detailed overview of {$page->title}. "
. "Include flavor profile, food pairings, and ideal serving temperature. "
. "Vintage: {$page->vintage}. Region: {$page->region}.",
'options' => ['maxTokens' => 600, 'temperature' => 0.6],
],
[
'field' => 'ai_brand_facts',
'prompt' => "Share 3 interesting facts about {$page->brand->title} "
. "that most people don't know. Be engaging and surprising.",
'systemPrompt' => 'You are a wine historian. Write in a friendly, conversational tone.',
'options' => ['maxTokens' => 400],
],
[
'field' => 'ai_review_summary',
'prompt' => "Summarize these customer reviews in 3 sentences. "
. "Mention common praise and any complaints:\n\n"
. $page->reviews->implode("\n---\n", 'body'),
'options' => ['temperature' => 0.3, 'maxTokens' => 300],
],
[
'field' => 'ai_food_pairing',
'prompt' => "Suggest 5 specific food pairings for {$page->title}. "
. "Format as a simple list.",
'options' => ['provider' => 'google', 'model' => 'gemini-flash-lite-latest'],
],
], [
// Global options — apply to all blocks unless overridden
'cache' => 'M',
'temperature' => 0.7,
]);
// Use in template
if ($results['ai_overview']['success']) {
echo "<section class='ai-overview'>{$results['ai_overview']['content']}</section>";
}
if ($results['ai_brand_facts']['success']) {
echo "<aside class='did-you-know'>";
echo "<h3>Did you know?</h3>";
echo $results['ai_brand_facts']['content'];
echo "</aside>";
}
Each block checks its field first — if content exists, returns instantly from the field without calling AI. Blocks can use different providers (e.g. cheap model for simple tasks, powerful model for detailed analysis).
Block structure:
| Key | Type | Required | Description |
|---|---|---|---|
field |
string | ✅ | Page field to save to |
prompt |
string | ✅ | AI prompt for this block |
options |
array | — | Per-block overrides (provider, model, maxTokens, temperature, cache) |
systemPrompt |
string | — | Shortcut for options['systemPrompt'] |
overwrite |
bool | — | Per-block overwrite (overrides global) |
// site/ready.php — regenerate all AI blocks when product data changes
$wire->addHookAfter('Pages::saved', function(HookEvent $event) {
$page = $event->arguments(0);
if ($page->template->name !== 'product') return;
if (!$page->isChanged()) return;
$ai = $this->modules->get('AiWire');
$ai->generate($page, [
['field' => 'ai_overview', 'prompt' => "Write overview for: {$page->title}..."],
['field' => 'ai_brand_facts', 'prompt' => "Facts about {$page->brand->title}..."],
['field' => 'ai_review_summary', 'prompt' => "Summarize reviews..."],
], ['overwrite' => true, 'temperature' => 0.5]);
});
| Method | Use case |
|---|---|
ask() |
One-off AI calls, no persistence needed |
ask() + cache |
Runtime rendering, temporary storage |
askAndSave() |
Single field, simple prompt, permanent storage |
generate() |
Product pages, articles — multiple AI blocks with individual settings |
AiWire writes to ProcessWire’s log system. View logs at Setup → Logs.
| Log file | Content |
|---|---|
aiwire |
Successful responses with provider/model/token info |
aiwire-errors |
Failed requests, API errors |
aiwire-debug |
Detailed request/response data (when debug enabled) |
Enable debug logging in module config for troubleshooting. Disable it in production.
gpt-5-nano or gemini-flash-lite-latest for high-volume, low-cost tasksaskWithFallback() in production to ensure uptimeMIT — free for personal and commercial use.
Maxim Alex — smnv.org — maxim@smnv.org
Built for the ProcessWire community.
← Back to README.md