# SCOUT integration brief — for AI coding agents

You are reading the canonical integration guide for **SCOUT**, the AI gateway and
shop-intelligence service for 506's Shopify apps. This document is self-contained:
everything needed to connect the app you are working on to SCOUT is below. It is
served from the gateway itself at `GET /llms.txt`, so the host you fetched it from
is the gateway base URL.

SCOUT has two surfaces in one Express process:

| Surface | What it does | You integrate when… |
|---------|--------------|---------------------|
| **Gateway** (`POST /ai/complete`, `POST /ai/feedback`) | Real-time LLM completions with per-app auth, feature registry, caching, rate limits, budgets, spend tracking | the app needs AI-generated text |
| **Enrichment** (`POST /enrichment/api/shop-context`) | Shop registry + storefront intelligence keyed by `myshopify.com` domain | the app should keep SCOUT's picture of each installed shop fresh |

Do not call Anthropic (or any LLM provider) directly from an integrated app — all
completions go through the gateway so auth, policy, spend, and observability stay
in one place.

---

## 1. Configuration contract

The app reads three environment variables. Never hardcode values; add them to the
app's `.env.example` with placeholders and read them from `process.env` (or the
app's config layer):

```bash
AI_GATEWAY_URL=https://<gateway-host>        # base URL — the host serving this file
AI_GATEWAY_KEY=<per-app bearer key>          # issued by the SCOUT operator (GATEWAY_KEY_* on the gateway)
AI_GATEWAY_APP=<app id>                      # e.g. easyscan | easygift | sale_discount
```

- The bearer key **identifies the app**. In production the gateway derives the app
  from the key; any `app` field in the request body is not trusted.
- Treat SCOUT as an **optional dependency**: if `AI_GATEWAY_URL` or
  `AI_GATEWAY_KEY` is unset, AI features should be hidden or disabled gracefully —
  never crash the app.
- If you need a key or a new app id registered, stop and ask the operator; you
  cannot mint keys yourself.

## 2. Minimal client

Adapt this to the app's HTTP conventions (fetch/axios, JS/TS). Keep the shape:
one `complete()` call, one `feedback()` call, a ~20 s timeout, and errors that
carry the gateway's HTTP status + error code so callers can fail soft.

```javascript
const GATEWAY_URL = process.env.AI_GATEWAY_URL;
const GATEWAY_KEY = process.env.AI_GATEWAY_KEY;

function isConfigured() {
  return Boolean(GATEWAY_URL && GATEWAY_KEY);
}

/** POST /ai/complete → { text, requestId, model, usage, costUsd, cached, responseTruncated } */
async function complete(feature, prompt, { storeId, storeContext, skipCache, cacheKey } = {}) {
  const res = await fetch(`${GATEWAY_URL}/ai/complete`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${GATEWAY_KEY}` },
    body: JSON.stringify({ feature, prompt, storeId, storeContext, skipCache, cacheKey }),
    signal: AbortSignal.timeout(20000),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(body.message || body.error || `gateway_http_${res.status}`);
    err.status = res.status;
    err.code = body.error;
    throw err;
  }
  return body;
}

/** POST /ai/feedback — thumbs on a previous completion, by requestId. */
async function feedback(requestId, rating, { category, comment } = {}) {
  const res = await fetch(`${GATEWAY_URL}/ai/feedback`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${GATEWAY_KEY}` },
    body: JSON.stringify({ requestId, rating, category, comment }),
    signal: AbortSignal.timeout(10000),
  });
  if (!res.ok) throw new Error(`feedback_http_${res.status}`);
  return res.json();
}
```

## 3. `POST /ai/complete` — the contract

Headers: `Content-Type: application/json`, `Authorization: Bearer <AI_GATEWAY_KEY>`.

| Field | Required | Rules |
|-------|----------|-------|
| `feature` | Yes | Registry id, `[a-z][a-z0-9_]*`, ≤ 64 chars, **must already exist** for this app in the gateway's feature registry (e.g. `sku_generator`). Send it **without** the app prefix. |
| `prompt` | Yes | Non-empty string, ≤ 24 000 chars. Usually a stringified JSON payload of the facts the model needs. |
| `storeId` | No | Shop hostname, e.g. `shop.myshopify.com` — always send it when acting for a shop; it drives per-shop rate limits and log attribution. |
| `storeContext` | No | String or plain JSON object of merchant facts to ground the model; serialized size ≤ 32 000 chars. |
| `skipCache` | No | Boolean. `true` bypasses the response cache. |
| `cacheKey` | No | 1–256 printable chars, no whitespace. See caching below. |

**Success (200)** returns JSON with:

- `text` — the completion (cap 96 000 chars; `responseTruncated: true` if cut)
- `requestId` — UUID; keep it if the UI lets merchants rate the response
- `model`, `usage`, `costUsd`, `cached`, `storeId`

**System prompts live server-side.** Each feature has a base system prompt in the
gateway registry, and the gateway appends a shared runtime block (UTC clock,
Shopify domain rules, safety). The app only sends the *user* prompt/data — do not
duplicate system instructions client-side.

**Errors** — handle by status, fail soft (log, degrade the feature, never crash):

| Status | Codes | What to do |
|--------|-------|------------|
| 400 | `invalid_prompt`, `prompt_too_long`, `invalid_feature`, `invalid_store_id`, `store_context_too_large`, `invalid_skip_cache`, `invalid_cache_key` | Bug in the calling code — fix the request; do not retry. `invalid_feature` usually means the feature isn't registered yet (ask the operator to add it on the portal Features page). |
| 401 / 403 | auth | Key missing/wrong. Surface a config error; do not retry. |
| 402 | monthly budget exceeded | Disable the AI feature until next month / operator raises budget. Do not retry. |
| 429 | `rate_limit_exceeded` (per-minute), `daily_limit_exceeded` (per-shop per UTC day, default 2000) | Honour `Retry-After` (seconds). For daily limits, back off until UTC midnight. |
| 5xx | provider/internal | One retry with backoff is reasonable; then degrade. |

**Caching.** Default cache key = hash of `{ feature, prompt, storeContext, utcDay }` —
the UTC day is included so "today"-dependent prompts don't go stale. If the prompt
embeds drifting numbers but is semantically stable, pass a `cacheKey` you control
(e.g. `analytics:{shop}:range=30d:since=2026-07-01`) — it replaces `prompt` (and
the UTC day) in the hash, namespaced per app+feature. Per-feature TTLs are set in
the registry.

## 4. `POST /ai/feedback` — merchant ratings

Call after a merchant rates a completion. Same bearer auth.

Body: `requestId` (from the completion response), `rating` (`1` or `-1`),
optional `category` (`inaccurate` | `off_tone` | `too_long` | `too_short` |
`great` | `other`), optional `comment` (≤ 2000 chars).
Success: `{ "ok": true }`. The gateway verifies the requestId belongs to your app
(`403 request_app_mismatch` otherwise); `404 request_not_found` for unknown ids.

## 5. Enrichment — keep the shop registry fresh

If the app receives Shopify shop data (on install and on `shop/update` webhooks),
push it to SCOUT so completions can be grounded in current merchant facts:

```
POST {AI_GATEWAY_URL}/enrichment/api/shop-context
Authorization: Bearer <AI_GATEWAY_KEY>          # same per-app key
Content-Type: application/json

{ "myshopifyDomain": "shop.myshopify.com", "shop": { ...Shopify Admin shop JSON... } }
```

- `shop` is the Admin API shop object (or your sanitized subset); the gateway
  allow-lists fields server-side.
- 200 returns the stored context row; `400 invalid_domain` / `invalid_body` on bad
  input. Fire-and-forget with a short timeout is fine — never block install flows.
- Reading the registry (`GET /enrichment/api/shops`, segments, ICPs) is an
  operator/portal surface, not an app surface — don't wire app code to it.

## 6. Feature registry — before you ship

Every `feature` id must exist in the gateway registry (Postgres `features` table,
managed on the portal **Features** page) with a system prompt, model tier, and
token cap. If you are adding a *new* AI capability:

1. Pick a feature id: lowercase + underscores, descriptive (`inventory_digest`).
2. Ask the operator to register it (they can draft the system prompt in the portal),
   or hand them: description, system prompt, example input/output, max tokens,
   cache TTL.
3. Until it exists, `/ai/complete` returns `400 invalid_feature`.

## 7. Integration checklist

1. Add `AI_GATEWAY_URL`, `AI_GATEWAY_KEY` (+ optional `AI_GATEWAY_APP`) to the
   app's env handling and `.env.example` (placeholders only — never commit keys).
2. Add the client module (section 2) in the app's house style; gate every call on
   `isConfigured()`.
3. Call `complete(feature, prompt, { storeId, storeContext })` from the feature
   code; handle the error table above; surface `text` to the user.
4. If the UI shows ratings, store `requestId` and wire `feedback()`.
5. On install / shop-update webhooks, POST the shop JSON to
   `/enrichment/api/shop-context` (fire-and-forget).
6. Verify:
   ```bash
   curl {AI_GATEWAY_URL}/health         # → {"status":"ok",...}
   curl -X POST {AI_GATEWAY_URL}/ai/complete \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer $AI_GATEWAY_KEY" \
     -d '{"feature":"<registered feature>","prompt":"ping","storeId":"dev-store.myshopify.com"}'
   ```
   Expect JSON with a `text` field (or a specific error code from the table above).

## 8. Rules

- Never log or commit the bearer key; redact `Authorization` headers in app logs.
- Keep prompts under the caps (24k prompt / 32k storeContext) — truncate inputs
  client-side rather than triggering 400s.
- One completion per user action; do not poll or loop `/ai/complete`.
- The gateway is the only LLM entry point — no direct provider SDK calls in
  integrated apps.
