# StoreAgent AI for WooCommerce

WordPress/WooCommerce plugin adding AI features to a store: chatbot, product description writer, tag generator,
review summaries, image alt tags, product Q&A, coupon generator, and more.

- **PHP namespace:** `SAAI` · **Text domain:** `storeagent-ai-for-woocommerce` · **REST namespace:**
  `/storeagent-ai/v1/`
- **Build, test, lint and architecture:** see [CLAUDE.md](./CLAUDE.md)
- **WP-CLI commands:** see [docs/CLI_COMMANDS.md](./docs/CLI_COMMANDS.md)

This README covers the piece neither of those does: **how this plugin is wired to the backend, and how to run that
whole chain locally.**

## Contents

- [Where this plugin sits](#where-this-plugin-sits)
- [Local development](#local-development)
- [`wp-config.php` constants](#wp-configphp-constants)
- [How the plugin reaches the Worker](#how-the-plugin-reaches-the-worker)
- [The indexing pipeline](#the-indexing-pipeline)
- [Troubleshooting](#troubleshooting)
- [Further reading](#further-reading)

## Where this plugin sits

The plugin never talks to OpenAI. Every AI call goes to the **StoreAgent Masked API** — a Cloudflare Worker that
authenticates the store, checks its plan, and owns the model keys, Postgres chat memory and pgvector search.

```
storefront widget / wp-admin
        │
        ▼
this plugin  (REST: /wp-json/storeagent-ai/v1/…)
        │   Basic Auth + X-Origin
        ▼
Masked API Worker  ──►  OpenAI (via Cloudflare AI Gateway)
        │          ──►  Postgres: chat history, feedback, pgvector
        │
        └──► back into this store for tool calls
             (products, orders, prices, chat settings, escalation email)
```

That last arrow matters: the chatbot calls **back** into the store on most messages. A Worker that cannot reach the
store still replies — just with no catalogue data.

## Local development

The full local runbook lives in the Worker repo, because most of the setup is on that side (Cloudflare login, KV
seeding, Postgres, TLS):

> **[storeagent-masked-api → "Local Development — Chat End-to-End"](https://github.com/Rymera-Web-Co/storeagent-masked-api/blob/trunk/README.md#local-development--chat-end-to-end)**

Follow that end to end. What belongs to *this* plugin within it:

| Worker-repo step | What you do here |
| --- | --- |
| Step 6 — Connect the test store | Set the two base-URL constants below, then complete **Connect to StoreAgent** in this plugin's dashboard |
| Step 8 — Route API calls to the local Worker | Either a mu-plugin that intercepts `/api/*`, or repoint `STOREAGENT_AI_BASE_URL` |
| Step 10 — Serve the chat widget script | Set `STOREAGENT_CHAT_SCRIPT_URL` |
| Step 11 — Index the catalogue | Run the indexing pipeline described below |

You also need an **entitled** account on the app site — a published plan plus an active subscription — or every call
returns `403 plan_limit_reached`. That precondition is spelled out in the Worker repo's Prerequisites.

## `wp-config.php` constants

All three are optional; the defaults target production.

| Constant | Default | Purpose |
| --- | --- | --- |
| `STOREAGENT_AI_BASE_URL` | `https://app.storeagent.ai` | Origin for every backend call. Point it at a local Worker (e.g. `https://127.0.0.1:8787/`) to bypass the app site entirely |
| `STOREAGENT_AI_API_ENDPOINT` | `api` | Path segment appended to the base URL. `api-beta` opts the site into the beta Worker prefix |
| `STOREAGENT_CHAT_SCRIPT_URL` | `{base}/chat/chat.js` | Where the storefront loads the chat widget bundle from — served by `storeagent-chat`, not this plugin. Checked in `Chat_Widget` *after* the `storeagent_chat_widget_script_url` filter, so the constant wins over that filter. Applies to the floating widget; the inline widget keeps its own filter |

```php
// Local: talk straight to a Worker running on this machine.
define( 'STOREAGENT_AI_BASE_URL', 'https://127.0.0.1:8787/' );
define( 'STOREAGENT_AI_API_ENDPOINT', 'api' );
define( 'STOREAGENT_CHAT_SCRIPT_URL', 'https://app.storeagent.test/wp-content/plugins/storeagent-chat/dist-chat/chat.js' );
```

Both URL values are filterable — `storeagent_ai_base_url` and `storeagent_ai_api_endpoint` — so a mu-plugin can
override them per environment without editing `wp-config.php`.

Resolution happens in `Helper::get_storeagent_base_url()`: `trailingslashit( STOREAGENT_AI_BASE_URL ) .
STOREAGENT_AI_API_ENDPOINT`. Pointing the base URL at the Worker directly is the simplest local setup, but it also
repoints browser-facing endpoints — which breaks the login redirect and chat-history restore. See the trade-offs under
the Worker repo's Step 8.

> Repointing the base URL is a **local-only** convenience. Never ship a site with it set to anything but the app
> origin.

## How the plugin reaches the Worker

Server-to-server calls use WooCommerce-style credentials issued by `storeagent-auth` at connect time and stored in
`_storeagent_consumer_key` / `_storeagent_consumer_secret`:

```
Authorization: Basic base64( consumer_key ":" consumer_secret )
X-Origin:      <store host, from get_home_url()>
Content-Type:  application/json
```

The Worker hashes the incoming key with HMAC-SHA256 (matching WooCommerce's `wc_api_hash()`) and compares it against
a KV record keyed on `X-Origin`. Both headers are required — a missing `X-Origin` is a 401, not a 400.

Endpoints this plugin calls, relative to the resolved base URL:

| Endpoint | Built in | Used by |
| --- | --- | --- |
| `/storeagent/v1/memory/upload-content-batch` | `Data_Upload` | Batched memory upload — the normal indexing path |
| `/storeagent/v1/memory/upload-content` | `Data_Upload` | Single-item fallback, when `saai_use_batch_memory_upload` is filtered false |
| `/storeagent/v1/memory/create` | `Create_Memory` | Provisions the account's vector store |
| `/storeagent/v1/memory/trim`, `/memory/data` | `Data_Upload` | Prune and inspect stored memory |
| `/storeagent/v1/plan` | `Plan_Manager` | Plan name, limits and usage — drives quota checks and the dashboard |
| `/storeagent/v1/auth-tokens/{token}`, `/auth/disconnect` | `Admin_Menu_Handler`, `Connect` | Connect / disconnect handshake |
| `/storeagent/v2/chat/message` | `REST\Chat\Chat` | Chatbot turns, proxied through this plugin's own `/chat/message` route |
| `/storeagent/v2/agents/{agent}` | `Abstract_AI_Agent` | Every individual AI agent |
| `{base}/feeds/agents.json` | `Helper::get_ai_agents()` | Public agent registry — no auth, and note it hangs off the **base** URL, not the `api` prefix |

The store's own chat route (`/wp-json/storeagent-ai/v1/chat/message`) is public and mints a short-lived
`aiToolToken` per turn, which the Worker presents back when calling the store's tool endpoints. Calling the Worker's
chat endpoint directly without that token fails with `Missing restUrl or aiToolToken required for chat tool calls`.

## The indexing pipeline

Chat answers product questions from a pgvector table the Worker creates on first ingest (`store_{user_id}`). Until
this plugin uploads memory, that table does not exist and chat replies with no store knowledge.

**Where it lives:** WooCommerce → StoreAgent → **AI Chat Settings → General**. After a failed run, that screen shows
an "Indexing needs attention" panel with a **Resume indexing** button.

**How it runs:** `Data_Upload` enqueues one Action Scheduler job per enabled post type
(`saai_post_data_upload`), batches posts, and hashes each payload so unchanged content is skipped on later runs. A
watchdog (`saai_upload_watchdog`) re-arms a queue that gets dropped mid-run.

### The `DISABLE_WP_CRON` trap

Most local stacks set:

```php
define( 'DISABLE_WP_CRON', true );
```

Action Scheduler then has nothing driving it, so **Resume indexing enqueues work and appears to do nothing**. Drain
the queue by hand:

```shell
wp action-scheduler run --hooks=saai_post_data_upload --batch-size=10
```

Each run is bounded by a time budget and re-queues its remainder, so a large catalogue needs several passes.

### Failure handling — and why a bad run costs you a day

Per post: up to **3 retries**, then the post is parked in a **24-hour cooldown** with `403`/`400` recorded in
`saai_post_data_upload_last_error_code`. **Resume indexing** is what clears those cooldowns, resets retry counters,
and re-queues everything — so a run attempted before the backend is actually working burns the retry budget for the
day unless you resume again afterwards.

Batch uploads fail at **batch level**: one malformed item rejects every item in that request. A single product can
therefore block its whole batch.

Relevant post meta, all prefixed `saai_post_data_upload_`: `timestamp` (set only on success), `hash`, `retry_count`,
`cooldown`, `last_error_code`, `last_error`.

### Driving and checking it from the CLI

```shell
# Trigger the same REST route the button calls.
wp eval '
$admins = get_users( array( "role" => "administrator", "number" => 1, "fields" => "ID" ) );
wp_set_current_user( (int) $admins[0] );
$res = rest_do_request( new WP_REST_Request( "POST", "/storeagent-ai/v1/chat-post-types/resume-indexing" ) );
echo wp_json_encode( $res->get_data() ) . "\n";
'

# Then drain the queue, and check where it landed.
wp action-scheduler run --hooks=saai_post_data_upload --batch-size=10

wp eval '
$u  = \SAAI\Classes\Memory\Data_Upload::instance();
$st = $u->get_per_post_type_index_status( \SAAI\Helpers\Helper::get_post_types_with_ai_memory_enabled() );
echo wp_json_encode( $u->compute_readiness_from_status( $st ) ) . "\n";
'
```

`{"ready":true,"state":"ready","indexed":16,"failed":0,"pending":0,"percentage":100}` is the goal. `readiness` also
carries the gate the storefront uses: `threshold` (percent indexed) and `min_items`.

Failures are logged to WooCommerce → Status → Logs under source `storeagent-ai-for-woocommerce`, which is the first
place to look — it records the HTTP status the Worker returned.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| Resume indexing does nothing, no log entries | `DISABLE_WP_CRON` with no system cron — the queue never drains | `wp action-scheduler run --hooks=saai_post_data_upload` |
| Log: `status: 401` on every upload | The Worker found no site-auth record for `X-Origin`, **or** its KV binding is missing. Both produce the same message | Verify the Worker's bindings, then its site-auth entry (Worker repo Steps 5 and 7) |
| Log: `status: 403` on every upload | No plan permission for the account. Locally this is usually a namespace mismatch, not a real entitlement problem | Worker repo Step 7 |
| Log: `status: 400` on products only | A variable product with **no variations** makes `get_variation_price( 'min' )` return `false` (it is `current()` of an empty array), and batch validation rejects the whole batch | Fixed Worker-side by coercing PHP-`false` prices. On an older Worker, publish or delete the empty variable product |
| `Failed to fetch subscription plans` | The plan endpoint is unreachable. Non-fatal — uploads continue — but quota checks fall back | Check the Worker's outbound TLS (Worker repo Step 9) |
| Chat replies but never cites products | The Worker cannot call back into this store | Worker repo Step 9 — the `fetch-chat-settings` trace step confirms it |
| `cURL error 28: Operation timed out after 30002ms` | This plugin's own 30s `wp_remote_post` ceiling | Not a Worker bug; test the Worker directly with a longer timeout |

## Further reading

- [storeagent-masked-api/README.md](https://github.com/Rymera-Web-Co/storeagent-masked-api/blob/trunk/README.md) — the Worker: full local runbook, bindings,
  security pipeline, endpoint reference
- [CLAUDE.md](./CLAUDE.md) — build, test, lint, architecture
- [docs/CLI_COMMANDS.md](./docs/CLI_COMMANDS.md) — WP-CLI commands
- [docs/N8N_STORE_DATA_INTEGRATION.md](./docs/N8N_STORE_DATA_INTEGRATION.md) — store-data endpoints used by the
  n8n-era pipeline
