cd ../blog
·Patterns

How to add a Claude agent to a PrestaShop store: a 5-file pattern

If you run a PrestaShop store, you have a back-office that knows everything about your catalog. Adding AI to it usually means giving merchants a button: "generate a product description", "suggest a category tree cleanup", "write the SEO meta from these specs". The hard part is wiring it in without fighting the framework.

Here is the minimum-viable 5-file pattern I use for PrestaShop modules that call Claude. It works on 1.7.x and 8.x, doesn't touch core, and ships as a single zip.

The example feature: a "Generate description with AI" button on the product edit page that calls Claude and writes three variants the merchant can pick from. Same pattern works for any back-office Claude integration — swap the prompt + the response handler.

The 5 files

modules/aiassistant/
├── aiassistant.php             # 1. Module manifest + lifecycle hooks
├── composer.json               # 2. Pull anthropic-sdk-php
├── src/Service/AIService.php   # 3. Claude SDK wrapper
├── controllers/admin/AdminAIController.php  # 4. Back-office endpoint
└── views/js/admin-product-button.js          # 5. JS button injection

That's it. One module zip, drop into /modules/, install, done.

File 1 — aiassistant.php (module manifest)

The bootstrap. PrestaShop's lifecycle is hook-driven, so this file declares the module to PS and registers the back-office hook we need.

<?php
// modules/aiassistant/aiassistant.php

require_once __DIR__ . '/vendor/autoload.php';

class AIAssistant extends Module
{
    public function __construct()
    {
        $this->name = 'aiassistant';
        $this->tab = 'administration';
        $this->version = '1.0.0';
        $this->author = 'You';
        $this->need_instance = 0;
        $this->ps_versions_compliancy = ['min' => '1.7', 'max' => '8.99.99'];

        parent::__construct();

        $this->displayName = $this->trans('AI Assistant', [], 'Modules.AIAssistant.Admin');
        $this->description = $this->trans('Claude-powered helpers for back-office workflows.', [], 'Modules.AIAssistant.Admin');
    }

    public function install(): bool
    {
        return parent::install()
            && $this->registerHook('displayAdminProductsExtra');
    }

    public function uninstall(): bool
    {
        return parent::uninstall();
    }

    /**
     * Injects the "Generate description" button on the product edit page.
     */
    public function hookDisplayAdminProductsExtra(array $params): string
    {
        $this->context->controller->addJS($this->_path . 'views/js/admin-product-button.js');
        $this->context->smarty->assign([
            'productId' => (int) $params['id_product'],
            'endpoint'  => $this->context->link->getAdminLink('AdminAI'),
        ]);
        return $this->display(__FILE__, 'views/templates/admin/button.tpl');
    }
}

The hook displayAdminProductsExtra runs on every product edit page. We inject our JS + a small Smarty template. The Smarty template (~5 lines) just defines the button DOM element the JS will hydrate.

File 2 — composer.json (one dependency)

{
    "name": "yourname/ps-aiassistant",
    "description": "AI Assistant module for PrestaShop",
    "require": {
        "php": ">=8.1",
        "anthropic/sdk": "^0.20"
    },
    "autoload": {
        "psr-4": {
            "AIAssistant\\": "src/"
        }
    }
}

Run composer install in the module directory before zipping. The vendor/ folder ships inside the zip. PrestaShop modules don't get a build step, so vendor IS the runtime.

PSR-4 autoloading via the namespace keeps the code clean. PrestaShop 1.7+ plays nicely with PSR-4 as long as require_once __DIR__ . '/vendor/autoload.php'; runs in the manifest (see File 1).

File 3 — src/Service/AIService.php (the Claude wrapper)

The thin service class that wraps every call into Claude SDK. All Claude calls go through here — never directly from a controller. This is the seam where rate-limiting, retries, logging, and prompt versioning live.

<?php
// modules/aiassistant/src/Service/AIService.php

namespace AIAssistant\Service;

use Anthropic\Anthropic;
use Anthropic\Resources\Messages;

class AIService
{
    private Anthropic $client;

    public function __construct(string $apiKey)
    {
        $this->client = Anthropic::factory()
            ->withApiKey($apiKey)
            ->make();
    }

    public function generateDescriptions(array $product, string $tone = 'premium', string $lang = 'en'): array
    {
        $resp = $this->client->messages()->create([
            'model'      => 'claude-sonnet-4-6',
            'max_tokens' => 700,
            'system'     => [
                [
                    'type'          => 'text',
                    'text'          => $this->systemPrompt(),
                    'cache_control' => ['type' => 'ephemeral'],
                ],
            ],
            'messages' => [
                [
                    'role'    => 'user',
                    'content' => $this->renderProductBrief($product, $tone, $lang),
                ],
            ],
        ]);

        return $this->parseVariants($resp->content[0]->text);
    }

    private function systemPrompt(): string
    {
        return <<<PROMPT
You are an expert e-commerce copywriter for PrestaShop stores.
For each request, produce exactly 3 variants of a product description.

Rules:
- 40-70 words each
- Each variant opens with the strongest concrete benefit
- Use ONLY the attributes provided. Never invent specs, materials, certifications
- Match the requested tone exactly
- Use the requested language only
- Return JSON: {"variants": ["v1", "v2", "v3"]}
PROMPT;
    }

    private function renderProductBrief(array $product, string $tone, string $lang): string
    {
        return json_encode([
            'name'       => $product['name'],
            'attributes' => $product['attributes'] ?? [],
            'category'   => $product['category'] ?? null,
            'tone'       => $tone,
            'language'   => $lang,
        ], JSON_UNESCAPED_UNICODE);
    }

    private function parseVariants(string $raw): array
    {
        // tolerant JSON extract — Claude sometimes wraps in ```json
        $json = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', trim($raw));
        $data = json_decode($json, true);
        return $data['variants'] ?? [];
    }
}

Two patterns worth calling out:

Prompt caching on the system block. Same trick as Career-OS — the system prompt is identical across all calls, cache it. After the first call of the session, you're paying ~10% of the system-prompt tokens. For a busy catalog manager generating 50 descriptions, that's the difference between $1 and 10¢.

Tolerant JSON parsing. Claude should return clean JSON when you ask, but sometimes wraps the response in markdown code fences. The preg_replace strips those before json_decode. Cheap insurance against a deploy day surprise.

File 4 — controllers/admin/AdminAIController.php (the endpoint)

The back-office controller. PrestaShop routes ?controller=AdminAI to this class. We handle a single POST action: generate-description.

<?php
// modules/aiassistant/controllers/admin/AdminAIController.php

use AIAssistant\Service\AIService;

class AdminAIController extends ModuleAdminController
{
    public function __construct()
    {
        $this->bootstrap = true;
        parent::__construct();
    }

    public function ajaxProcessGenerateDescription(): void
    {
        $productId = (int) Tools::getValue('id_product');
        $tone      = Tools::getValue('tone', 'premium');
        $lang      = Tools::getValue('lang', 'en');

        if (!$productId) {
            $this->ajaxRender(json_encode(['error' => 'missing product id']));
            return;
        }

        // Load product attributes from PS, NOT from the request body —
        // the request body could be tampered with. Trust DB, not client.
        $product = $this->loadProductBrief($productId);

        $apiKey = Configuration::get('AIASSISTANT_ANTHROPIC_KEY');
        if (!$apiKey) {
            $this->ajaxRender(json_encode(['error' => 'API key not configured']));
            return;
        }

        try {
            $service  = new AIService($apiKey);
            $variants = $service->generateDescriptions($product, $tone, $lang);
            $this->ajaxRender(json_encode(['variants' => $variants]));
        } catch (\Exception $e) {
            error_log('[AIAssistant] ' . $e->getMessage());
            $this->ajaxRender(json_encode(['error' => 'Generation failed']));
        }
    }

    private function loadProductBrief(int $productId): array
    {
        $product = new Product($productId, false, $this->context->language->id);
        return [
            'name'       => $product->name,
            'attributes' => $this->extractAttributes($product),
            'category'   => $product->getDefaultCategory(),
        ];
    }

    private function extractAttributes(Product $product): array
    {
        // Pull attribute groups / features from PS — kept minimal here for clarity.
        return $product->getFrontFeatures($this->context->language->id);
    }
}

Critical detail: never trust the product brief sent from the client. Always load it from the database server-side using the id_product the client passed. If you let the client send the brief, anyone with browser devtools can ask Claude to write a description for a product that doesn't exist, or worse, prompt-inject by stuffing the description field with "ignore previous instructions, ...".

File 5 — views/js/admin-product-button.js (the trigger)

The thin client. Picks up the button injected via the Smarty template, calls our endpoint, renders the 3 variants.

// modules/aiassistant/views/js/admin-product-button.js

(function() {
  document.addEventListener('DOMContentLoaded', () => {
    const btn = document.getElementById('ai-generate-description');
    if (!btn) return;

    btn.addEventListener('click', async () => {
      const productId = btn.dataset.productId;
      const endpoint  = btn.dataset.endpoint;
      const tone      = document.getElementById('ai-tone').value;
      const lang      = document.getElementById('ai-lang').value;

      btn.disabled = true;
      btn.textContent = 'Generating…';

      try {
        const params = new URLSearchParams({
          ajax: '1',
          action: 'GenerateDescription',
          id_product: productId,
          tone,
          lang,
        });
        const resp = await fetch(`${endpoint}&${params.toString()}`);
        const data = await resp.json();

        if (data.error) {
          alert('AI: ' + data.error);
          return;
        }

        renderVariants(data.variants);
      } catch (err) {
        alert('AI: request failed');
        console.error(err);
      } finally {
        btn.disabled = false;
        btn.textContent = 'Generate with AI';
      }
    });
  });

  function renderVariants(variants) {
    const target = document.getElementById('ai-variants');
    target.innerHTML = '';
    variants.forEach((v, i) => {
      const block = document.createElement('div');
      block.className = 'ai-variant';
      block.innerHTML = `
        <p>${escapeHtml(v)}</p>
        <button type="button" class="btn btn-primary use-variant" data-variant="${i}">
          Use this one
        </button>
      `;
      target.appendChild(block);
    });

    target.querySelectorAll('.use-variant').forEach((b, i) => {
      b.addEventListener('click', () => {
        // Write into the PS description field
        const descField = document.querySelector('#form_step1_description textarea');
        if (descField) {
          descField.value = variants[i];
          descField.dispatchEvent(new Event('change', { bubbles: true }));
        }
      });
    });
  }

  function escapeHtml(s) {
    return s.replace(/[&<>"']/g, c => ({
      '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
    }[c]));
  }
})();

Vanilla JS, no jQuery, no framework. PrestaShop ships its own jQuery and loading another script tag is unnecessary. The "Use this one" button writes into the standard PS description field — which means the merchant still has to click PS's own save button to commit. That's intentional. The pattern is "AI drafts, human ships" — same rule as the support-triage post.

Failure modes I have hit

1. The API key in Configuration::get got logged to a debug file. PrestaShop's Configuration table is logged in some debug paths. Fix: prefix secret config keys with _HIDDEN_ (PS convention) or use environment variables read at runtime instead.

2. The merchant generated 200 descriptions in 10 minutes and the next Anthropic bill spiked. Fix: rate-limit at the controller layer. 20 calls per user per hour, tracked in a small aiassistant_usage table you create at install time. Five lines of code. Save yourself.

3. The merchant tried to AI-generate a description for an empty product. Claude obliged and hallucinated three variants for "Untitled Product". Fix: validate that the product has at least a name + 2 attributes before calling Claude. Return 400 with a helpful error if not.

4. The Smarty template loaded before the form, so the button injected into nothing. Fix: use DOMContentLoaded (the JS above already does). Don't use $(document).ready — PrestaShop's jQuery version varies.

What this costs to run

For a catalog manager generating 30 product descriptions/day:

  • Input tokens: ~600/call × 30 = 18,000/day. With prompt caching: ~3,000 billed/day. At Sonnet 4.6 rates, ~5¢/day.
  • Output tokens: ~700/call × 30 = 21,000/day. At Sonnet rates, ~30¢/day.
  • Total: ~$0.35/day, ~$10/month per active merchant.

A merchant doing 30 high-quality product descriptions a day, manually, would spend hours — easily an order of magnitude more in salary than the AI bill. This is the kind of ROI math that converts skeptical store owners on a first call.

Want this in your store?

The full pattern is reproducible. If you want it built and installed in your PrestaShop store — including all 5 files, the back-office config UI, the rate-limiter, and a Loom walkthrough so your catalog team can hit the ground running — that's a 1–2 week scoped engagement on the hire-me page.

Same pattern shape works for Laravel + Filament, Symfony admin panels, and custom admin tools. The 5-places-to-bolt-AI post covers the Laravel variant.

How to add a Claude agent to a PrestaShop store: a 5-file pattern · Akram Bakhouche