# Aeolo GEO Setup & Content Feed Integration — v2

Set up a measurable GEO foundation, then add Aeolo articles to your existing website using its design and public URLs.
Your server or static build reads JSON Feed 1.1 and renders the article HTML.
The result must include working list, detail and sitemap routes, plus a refresh
mechanism. Rendering makes content available to crawlers; it does not guarantee
indexing, AI citations or traffic.

## Give this task to your coding agent

```text
Read https://aeolo.io/integrate/content-feed.md and execute its v2 workflow:
check/update the aeo CLI; understand the brand; audit GEO/SEO; set up or verify
GA4 and GSC; prepare indexing; secure the publishing surface; implement and
verify the content integration. Reuse working setup and existing content.

Preserve our existing design, posts, URLs, analytics ownership and metadata
conventions. Inspect the framework version, deployment mode, blog routes,
locales and sitemap first.
Use the guide's shared server loader for list, detail and sitemap; do not copy
only a framework snippet. Test pagination, failures and publish/update/removal.

Ask only for missing inputs/access: production article address, target markets,
languages, ownership tag, Google/DNS access and server environment configuration.
Record credit-consuming actions and the authorized publication/deployment scope.
Have me put the channel-bound key in AEOLO_KEY through our secret manager or ignored env file;
do not put credentials in this prompt, source code, logs or browser bundles.

Implement and test locally. Use our normal deployment process if authorized;
otherwise leave a tested change ready to deploy. Report files changed, test
results, actual URLs checked, refresh behavior and any remaining owner action.
Do not call an empty feed, a successful build or a verified host a completed
live integration.
```

## v2 workflow: diagnose → measure → prepare discovery → secure a surface → integrate

Run phases A–G in order. The technical recipe that follows implements the custom
feed branch. If the site already has a phase working, verify and reuse it.
Keep a checklist with `passed`, `failed`, `pending`, or `not applicable`; include
evidence, owner and next action for every pending row. Carry out actions within
the user's existing authorization; for missing OAuth access, budget or public
publishing authority, prepare the exact change and request only the missing input.
Don't repeatedly ask for approval already granted for the same scope.

| Phase | Deliverable | Exit condition |
|---|---|---|
| A. Tools and brand | Current CLI, correct account/domain, supported brand brief | Version/path verified; intended brand and target markets identified |
| B. GEO/SEO baseline | Dated site audit, AI visibility baseline, prioritized fixes | Scores have source/scope; blockers and content opportunities are concrete |
| C. GA4 and GSC | Collection, ownership and Aeolo selections | Actual test event plus verified property access and selected IDs |
| D. Indexing preparation | Sitemap plan and URL submission queue | Each candidate has canonical/indexability checks; unreleased URLs held |
| E. Publishing surface | Chosen host/path/channel and content map | Site can own and serve those URLs; navigation, routes and destination agreed |
| F. Implementation and launch | Feed/CMS integration, useful content, deployed readback | Raw HTML, analytics, sitemap and lifecycle verified on public URLs |
| G. Search submission and remeasurement | Submission receipts and comparable after-state | Prepared/submitted/indexed/cited/converting reported separately |

### A. Check the CLI and understand the brand

#### A1. Install or synchronize the actual executable

```bash
command -v aeo
aeo --version
```

If missing, install using the official instructions at
[aeolo-ai/aeo](https://github.com/aeolo-ai/aeo). The published installer is:

```bash
curl -fsSL https://skills.aeolo.io | sh
```

Check the current release/update notice. If outdated, update using the installed
CLI's update flow, then check again:

```bash
aeo update
command -v aeo
aeo --version
aeo --help
```

Don't pin this guide to a permanently “latest” version. Record the version and
executable path you actually ran. If multiple installations exist, inspect
`type -a aeo` and fix the selected PATH/install through its supported updater;
do not delete unrelated binaries. Read the installed `aeo` skill/reference when
available. Use the CLI's bare noun help (`aeo audit`, `aeo content`) and current
command reference before relying on flags. Some server-backed commands may be
missing from an older top-level help listing. Unknown-command errors are not
permission to invent a command or call internal/staff APIs.

**The site SEO/foundation score is currently exposed through `domain audit` and
`audit run`; there is no documented terminal `aeo seo` command.** Updating the
binary does not turn an unsupported noun into a supported feature. New command
surfaces must be verified against the current release's documentation.

#### A2. Authenticate and select the correct brand

```bash
aeo auth status
# If login is required, the owner completes the browser flow:
aeo auth login
aeo domain list
```

Pick the existing brand by its name and domain. Use its returned ID explicitly
on subsequent commands, especially if the agent handles several customers:

```bash
aeo domain setup --domain <domain-id>
aeo agent context --domain <domain-id>
aeo strategy show --domain <domain-id>
aeo channel list --domain <domain-id>
```

Only add a domain if it is genuinely a new brand; don't create a duplicate to
refresh an existing site's analysis. Never paste the contents of the CLI's auth
config into a report. `domain setup` is a product checklist, not proof of live
analytics collection, public rendering or indexing.

Write a brief from the brand context, actual website and owner materials:

1. What is sold, in what category, and in which markets/languages?
2. Which customer, situation and decision make the offering relevant?
3. What evidence supports choosing it over alternatives?
4. What is excluded or outside the offering's scope?
5. What single accurate sentence should an AI use to describe/recommend it?

Record actual product/service URLs, prices/specs with dates, real qualifications,
case evidence and known limitations. Flag unknown facts; do not fill them with
marketing guesses. Read existing brand/strategy data before proposing changes,
and preserve confirmed context. A software integration task alone does not
require rewriting the customer's strategy.

### B. Establish the GEO/SEO baseline and repair priorities

#### B1. Read the existing site score before starting a new audit

```bash
aeo domain audit --domain <domain-id>
aeo visibility show --domain <domain-id>
aeo visibility history --domain <domain-id>
aeo topics list --domain <domain-id>
aeo prompts list --domain <domain-id>
```

Record the audit date, audited host/channel, page count, overall/component scores
**as returned**, issues, and unavailable checks. If stale or absent, run a fresh
audit within the approved credit budget (cost scales with scope):

```bash
aeo audit run --max-pages 5 --domain <domain-id>
# Use the job ID from the response; poll about once per minute to terminal state.
aeo audit poll <job-id> --domain <domain-id>
aeo domain audit --domain <domain-id>
```

If targeting an existing publishing channel rather than the brand's primary
site, use `--channel-id <channel-id>` when supported by the installed command.
Record which target was actually audited. Never present a primary-store score
as the score for a separate article host. A returned job ID means “started,” not
“audited”; a failed/incomplete job is not a zero score. If permission/credits are
missing, retain the last report with its date and do the read-only page checks
below while the fresh audit stays pending.

Create a baseline table such as:

| Measure | Value | Evidence / scope |
|---|---|---|
| Site foundation score | Returned value or unavailable | Audit ID/date, host, sampled pages |
| Blocking technical findings | Count and URLs | Actual response/robots/canonical evidence |
| AI mention / recommendation / citation | Separate reported values | Engine, prompt set, market, date, repeat count |
| Organic search clicks / impressions | Measured or not connected | GSC property and reporting period |
| Identifiable AI referrals / key events | Measured or not collecting | GA4 property, event definition and period |

A foundation score is a diagnostic, not an AI recommendation probability. Do not
promise a universal passing score or turn absent data into measured zero.

#### B2. Verify the real pages and fix blockers first

Sample the homepage, a category/service page, a product/detail page and an article
in every supported route pattern. Inspect HTTP response and parsed server HTML:

| Priority | Inspect | Deliverable |
|---|---|---|
| P0 access | HTTP status/redirect chain, auth/WAF, robots rules, meta robots and `X-Robots-Tag`, body rendered without JS | Intended public pages reachable; private/staging pages stay protected |
| P0 URL identity | HTTPS/host redirects, one canonical, language paths, soft-404s, duplicate mounts | One deliberate public address per edition |
| P1 discovery | Real internal links, index/archive routes, sitemap/index/robots references | Each public article reachable and listed at its canonical |
| P1 answer/evidence | Title/H1, clear answer, specific claims, sources, author/publisher, true dates, product relevance | Useful visible content and support for material claims |
| P1 structured data | Appropriate Article/Product/etc., correct URL and visible facts, no duplicate/conflicting schemas | Valid markup matching the page; no invented ratings |
| P2 experience | Mobile layout, readable tables, image dimensions/alt, performance on the actual template | Measured problems fixed without replacing the design |

Distinguish search crawlers, training crawlers and user-triggered fetchers when
reviewing robots policy; preserve the owner's intended access choices. Do not
blanket-enable every bot or add hidden text solely for crawlers. Robots, schema
and an `llms.txt` file are not substitutes for a useful indexable page. Google's
[AI search guidance](https://developers.google.com/search/docs/appearance/ai-features)
uses existing SEO fundamentals rather than a special AI schema requirement.

#### B3. Turn brand understanding into an editorial map

Read the existing strategy and tracked set first. Choose coherent Topics, then
list distinct customer decisions within each: fit, alternatives, constraints,
usage, price/value, trust and follow-up needs where relevant. Ten genuinely
different decisions can be a useful planning target; never pad a Topic with ten
paraphrases. Fit the saved set to the customer's plan and approved scope.

Keep unbranded discovery questions separate from branded accuracy/perception
checks. “Is Brand X good?” cannot establish that AI discovers Brand X unaided.
For every proposed page record:

| Topic / customer decision | Market / language | Existing page or gap | Verified brand fit | Evidence | Planned URL / destination | Next action |
|---|---|---|---|---|---|---|
| A specific decision | Exact market/tag | Refresh / new page | Product/service and limitations | Source URLs/materials | Chosen owned surface | Brief / draft / review |

Prefer updating a relevant existing page over creating duplicates. A good brief
contains a direct answer, selection criteria or steps, real supporting evidence,
limitations, useful internal links, and a relevant next action. Choose a guide,
comparison, tutorial or case study because it fits the decision, not because a
format supposedly guarantees AI citations. Translations need valid local claims,
terminology and their own route; don't invent credentials or customer outcomes.

For an absent initial visibility baseline, prepare the exact prompt IDs,
engines, markets and credit estimate, then run within authorization:

```bash
aeo visibility check run --prompt-ids <id-1,id-2> --engines <approved-engines> --domain <domain-id>
aeo visibility check poll <job-id> --domain <domain-id>
aeo visibility show --domain <domain-id>
```

Do not regenerate or replace a populated tracked set merely to complete setup.
Preserve its IDs/text for a later comparison. Record partial provider failures
separately from measured non-mentions.

### C. Set up GA4 collection, Search Console ownership and Aeolo reads

These are three independent tasks. Connecting Google OAuth to Aeolo does not
install a GA tag, create a Google property or prove site ownership.

#### C1. Inventory existing measurement

Read the site's tag manager/analytics code and Google account access. Record:

- GA4 account/property and numeric property ID; web stream and its `G-…`
  measurement ID; exact production host; timezone/currency.
- Installation method: existing GTM container or direct Google tag; consent/CMP
  integration; SPA route-change behavior; existing key-event definitions.
- GSC property: `sc-domain:example.com` or exact URL-prefix property, owner/full
  user access, verification method and submitted sitemap(s).

Reuse the customer's existing setup. Numeric GA4 property IDs, `G-…` measurement
IDs and `GTM-…` container IDs are different things. Do not pass a measurement ID
as Aeolo's `--ga4-property` value.

#### C2. Install or repair collection in the website

If no appropriate GA4 property/web stream exists, create it in the customer's
Google account with their authorization and record the IDs. Use
[Google's setup instructions](https://support.google.com/analytics/answer/9304153).
If a suitable property exists but access is missing, request access instead of
creating a second one.

Implement the tag through **one** existing mechanism. For GTM, configure the
Google tag in the existing container and publish the reviewed container version.
For direct integration, use the framework's supported Google tag integration in
the shared layout with the actual measurement ID. A server feed read does not
collect page views; the public article template needs the same analytics coverage
as the rest of the site. Preserve consent behavior and CSP configuration.

Verify on a published article and a normal site page:

1. A consent-eligible test visit sends to the intended measurement ID.
2. GA4 Realtime or DebugView shows the expected page/event; record URL/time.
3. SPA navigation, if used, records the intended page change once. Avoid double
   page views from both enhanced measurement and a manual route listener.
4. Select one meaningful existing conversion/key event, e.g. completed inquiry
   or purchase. If adding one, define its exact trigger and verify once. Do not
   send form contents, email addresses or other personal data as event parameters.
5. Separate preview/internal test traffic from production reporting using the
   site's established policy. No data yet is not proof of a broken OAuth link.

Deliver the tag location/container version and test evidence. “Script present”
is weaker than “event received.” Historical reports may lag a successful test.

#### C3. Verify GSC ownership and coverage

Reuse the correct property. A Domain property needs DNS verification and covers
protocol/subdomain variants; a URL-prefix property covers its declared prefix.
Choose coverage that includes the real article host/path, including a managed
subdomain if one will be used. A property for the shop's `www` prefix may not
cover a new `guides` host.

Add the owner-supplied DNS record or verification file/meta tag without removing
existing owners or verification records. The Aeolo ownership tag is **not** a
Google verification tag. Read the public verification response and confirm the
Google property access/status in GSC. If DNS access is missing, prepare the exact
record and leave ownership pending; do not replace unrelated DNS records.
See [Google's ownership verification guide](https://support.google.com/webmasters/answer/9008080).

#### C4. Connect and select the properties in Aeolo

The owner connects their Google account in Aeolo Settings → Integrations via
browser OAuth. There is no CLI command here that can bypass consent. Then:

```bash
aeo integrations google status --domain <domain-id>
aeo integrations google properties --domain <domain-id>
aeo integrations google sites --domain <domain-id>
# Use values returned above, after checking property/host ownership:
aeo integrations google set --ga4-property <numeric-property-id> --gsc-site <exact-returned-site> --domain <domain-id>
aeo integrations google status --domain <domain-id>
aeo measure overview --domain <domain-id>
aeo measure traffic --days 30 --domain <domain-id>
```

`ready` means the resource is selected. Confirm both selected values and a
successful authorized read; separately retain C2's collection proof and C3's
ownership proof. For expired OAuth or denied access, have the owner reconnect or
grant access; don't keep retrying the same token. Report unavailable historical
metrics separately from valid zero values. Identifiable AI referral traffic is
only the subset carrying usable referral information; don't label all direct
traffic or all Google search clicks as AI traffic.

### D. Prepare indexing requests before publishing

Prepare the process now; submit only after phase F's public verification.
Create a URL inventory in the task's working output with these columns:

```text
url,locale,source_content_id,published_at,last_modified,http_status,canonical,
robots_allowed,noindex,body_verified,sitemap_url,gsc_property,
inspection_state,submission_state,submitted_at,receipt_or_blocker
```

Use actual returned canonical URLs and current public response evidence. Proposed
URLs remain `pending_publish`; never queue drafts, preview hosts, redirects,
missing pages, conflicting canonicals or unsupported languages as ready.
For each candidate verify 200 status, substantial server HTML, the intended
canonical, indexing directives and sitemap inclusion. Preserve accurate source
dates. Do not repeatedly bump dates just to trigger recrawling.

Prepare two separate delivery paths:

- **Google:** submit a discoverable sitemap in the matching GSC property for many
  URLs. For a small priority set, use GSC URL Inspection, its live test when
  relevant, and “Request indexing” after authorization. Record the actual result.
  The [URL Inspection API](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect) reads inspection data; it does not submit a general
  indexing request. `/aeo gsc index` in the agent skill describes a browser
  workflow, **not a terminal CLI verb**. Use the available authorized browser or
  provide the owner with exact URLs and manual steps. Do not fabricate a shell
  command or promise a fixed daily quota.
- **IndexNow:** where the channel/host supports it, prepare [host-key validation](https://www.indexnow.org/documentation)
  and the eligible canonical URLs separately. The channel command below enables
  IndexNow behavior; it is **not Google Search Console submission**. Check the
  current channel support and returned status before reporting anything submitted:

```bash
# Optional, only for the selected compatible channel and approved submissions:
aeo channel indexing <channel-id> --enabled true --backfill --domain <domain-id>
```

Google's general blog indexing path is sitemap/URL Inspection. Its
[Indexing API](https://developers.google.com/search/apis/indexing-api/v3/using-api)
is restricted to qualifying job postings and livestream pages; don't use it for
ordinary articles. [Recrawl requests](https://developers.google.com/search/docs/crawling-indexing/ask-google-to-recrawl)
have quotas, don't guarantee indexing and aren't accelerated by repeatedly
requesting the same URL. Stop when a limit is returned. A freshly updated indexed
page may merit recrawl; an unchanged already-indexed URL normally doesn't.

**Exit condition:** inventory, correct GSC property, sitemap location, submission
method and blockers are ready. At this stage “prepared” is the honest status.

### E. Secure a real publishing surface

“Surface secured” means an actual owned/authorized destination can serve the
planned URLs. A channel row alone is not enough. Read `aeo channel list` and the
site repository/hosting access before choosing:

| Existing situation | Use | Work required |
|---|---|---|
| Editable SSR/SSG site with a blog/resources section | Custom JSON Feed in that section | Steps 0–7 below, same design, real internal links, shared analytics |
| Editable site without an article area | An agreed `/blog` or `/guides` area | Index/detail routes, layout, navigation placement, language and sitemap plan |
| Connected native CMS (e.g. WordPress/Shopify) | Supported CMS publishing integration | Verify destination, permissions, theme HTML, metadata, analytics and sitemap; don't also build a duplicate feed blog |
| No website code access | Existing/authorized Aeolo managed surface or hosted blog | Configure in Sites, verify ownership/DNS and public destination; main-site navigation still needs the owner |
| External publisher/review site | Separate authorized distribution plan | Publisher permission/editorial placement and a real published URL; no assumed access or promised placement |

For an own-site custom surface, use its exact article mount, not the homepage:

```bash
aeo channel add --type custom --url https://yourdomain.com/blog --domain <domain-id>
aeo channel list --domain <domain-id>
aeo content feed --domain <domain-id>
```

Reuse an existing matching channel rather than duplicating it. A CNAME binds a
host; it cannot mount `/blog` under an existing host by itself. A subdirectory
needs code or a deliberately configured reverse proxy. Managed `*.aeolo.site`
surfaces and legacy `*.aeolo.blog` blogs are different destinations; inspect what
actually exists and do not substitute one silently.

Record a concrete surface contract before coding:

```text
Brand / owner:
Production origin:
Article mount(s) and list URLs:
Locale -> route convention:
Channel ID / channel type:
Renderer and deployment owner:
Existing posts and collision rule:
Navigation entry / related-page links:
Canonical and sitemap location:
GA4 stream / GSC property covering this host:
Refresh / removal mechanism:
Code, DNS, CMS and publication permissions still needed:
```

Link the new area from relevant navigation, category/product/service pages, and
contextual article links. A hidden orphan route with no entry point is not a
completed content surface. Preserve existing customer copy and approved drafts.

### F. Implement, publish a useful test page, and verify delivery

For the custom branch, complete technical steps **0–7 below**. A single shared
loader supplies list, detail and sitemap; the recipes include pagination, errors,
locales, canonical paths, caching and unpublish behavior. Add the GA integration
to these same layouts and put the new canonical URLs in D's inventory.

Select a real approved article suitable for the editorial map from B3. Read its
current content/state and destination. Check title, factual claims, sources,
product links, language, author/date, cover and CTA before public release.
A new draft can be written/imported through the installed skill; server-side
`content generate` is a paid action only when requested. A content job completing
or a preview opening does not prove publication. Never overwrite an unapproved
customer revision to obtain a test article.

```bash
aeo content list --status published --domain <domain-id>
aeo content get <content-id> --head --domain <domain-id>
aeo content review <content-id> --domain <domain-id>
```

Use the current skill's publishing workflow for that destination and existing
authorization. For custom feeds, verify the chosen article is published and
addressed to that custom channel; the site pulls it rather than receiving a CMS
push. If no approved published article exists, ask for a disposable test article
or approval of a prepared draft, while retaining the completed fixture tests.

After deployment, verify the actual URL, body, metadata, analytics event, sitemap
and update/removal lifecycle. Repeat the site audit on the same scope and record
before/after scores with dates. A deployed fix only counts as resolved when the
original failing public behavior has changed.

### G. Submit prepared URLs and measure the correct outcome

Move only F-verified URLs from `pending_publish` to `ready`. Submit through D's
chosen methods if authorized; record exact URL/property, timestamp, receipt and
failure/quota state. Check later indexing through GSC's observed state; don't
translate “request accepted” into “indexed.”

For subsequent measurement, retain the same prompt IDs/text, engines, markets,
languages and repeats as the baseline, or explicitly label changed scope. Read:

```bash
aeo domain audit --domain <domain-id>
aeo visibility history --domain <domain-id>
aeo visibility show --domain <domain-id>
aeo measure overview --domain <domain-id>
aeo measure traffic --days 30 --domain <domain-id>
```

Schedule a new paid visibility check only within the approved scope/budget. Compare
sufficient and comparable periods for traffic/key events; a same-day deployment
cannot establish business lift. Track these milestones independently:

`implemented → deployed → publicly verified → submission prepared → submitted → indexed → cited/mentioned → referred visit → customer action`

Report whichever are actually observed, including pending stages and their owner.
Do not invent a guaranteed wait time for indexing or AI citation.

---

## Technical implementation: custom JSON Feed

The remaining sections are the implementation manual for phase F. A reader sent
here for feed wiring alone can start at step 0 after confirming the origin,
channel, languages and deployment scope.

## 0. Discover the site and choose the implementation

Before editing, inspect `package.json`, the lockfile, framework/hosting config,
existing list/detail routes, content loader, layout, locale routing and sitemap.
Use the project's existing package manager, components and test runner.

```bash
rg --files app pages src public | rg 'blog|article|sitemap|layout|root|routes'
rg -n 'contentHtml|content_html|ReactMarkdown|MDXRemote|canonical|hreflang' app pages src
```

Missing directories are fine. Record the following decisions in your task notes:

| Decision | Required evidence |
|---|---|
| Framework and version | Installed dependency and router, not filename guesses |
| SSR or SSG | Hosting adapter, output mode, whether runtime server code exists |
| Production origin | Exact public origin, including `www` if used |
| Article mounts | Actual paths, e.g. `/blog`, `/guide`, or `/ko/blog` |
| Languages | Full tags, e.g. `en`, `ko`, `zh-Hans`, `zh-Hant`; default list language |
| Existing sources | Post shape, HTML/Markdown renderer, list filtering and pagination |
| Metadata | Where canonical, JSON-LD, ownership tag and `<html lang>` are emitted |
| Refresh | Runtime TTL or rebuild trigger, including sitemap and CDN caches |

Reuse an existing blog. Create one only if none exists. Adapt the examples to
that renderer instead of replacing the site's design. If only client-side
JavaScript is available, arrange SSR/SSG or a native CMS integration before
implementing this feed. A browser fetch with a private key is not an option.

## 1. Connect, verify, and configure

Create or open the custom site in Aeolo's Sites/channel settings. If already
connected, reuse it. The CLI creation alternative is:

```bash
aeo channel add --type custom --url https://yourdomain.com/blog
```

Keep the returned `feedUrl`, `verifyTag`/`verifyToken` and, when present,
`revalidateUrl`. Save `apiKey` and `revalidateSecret` only as server secrets.
The feed key is displayed once; an existing connection may return no new key.
Use key settings to issue a channel-bound replacement when necessary, rather
than repeatedly creating sites. An account-scoped CLI key cannot read the feed.

### Ownership can be verified before the blog is built

Put the exact supplied tag in the shared HTML head and deploy it:

```html
<meta name="aeolo-site-verification" content="aeo-YOUR-VERIFICATION-TOKEN">
```

Use a persistent root/shared layout, and check the returned HTML on `/` and the
article mount. React Router/Remix's last route `meta()` replaces parent metadata;
place this tag directly in the shared head or explicitly preserve it. Other
frameworks have different merge rules: inspect their actual HTML rather than
assuming all behave like Remix. Aeolo can check the configured URL and fall back
to the site's origin, so `/blog` does not need to exist before verifying `/`.
The verification token is public; the feed key is private.

Return to Aeolo and verify. Configure server-only variables in both local and
hosting environments (never `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_` keys):

```dotenv
AEOLO_KEY=              # set privately; never commit a real value
AEOLO_SITE_ORIGIN=https://yourdomain.com
AEOLO_FEED_URL=https://api.aeolo.io/v1/connector/feed.json
```

Ask the owner for configuration presence or a redacted error if you cannot access
the deployment environment. Continue using fixtures while a key is unavailable;
mark live verification pending.

### Decide addresses before implementing routes

The endpoint is `GET https://api.aeolo.io/v1/connector/feed.json` with
`Authorization: Bearer <channel-bound key>`. It returns the JSON Feed document
at the top level, not `{ success, data }`.

One key reads the connected own-site paths on the same host **within its brand**.
A key for `/blog` may therefore also return `/guide` articles. This does not
include other brands or hosts; `www.example.com` and `example.com` are different
hosts. Each item's `_geo.canonical` supplies its destination.

- **Preserve multiple mounts:** omit `base`. The feed uses the site's configured
  destinations; the verified channel address is the fallback. Implement each
  intended mount and filter other mounts out of that route's list.
- **Intentionally mount everything at one path:** set
  `?base=https%3A%2F%2Fyourdomain.com%2Fblog`. This overrides every item's base.
  Use it only when all these articles really belong there. Check for slug
  collisions before choosing this mode.
- **No verified address:** do not rely on fallback URLs. Complete configuration
  and inspect `_geo.canonical` before declaring success.

A connection may supply a `feedUrl` with `base` already set. Review it against the
above choice. Keep production canonicals on preview/local deployments; change
only the origin used to request test pages. Do not append a language query or
invent a translated slug. For locale prefixes, configure the real destinations
in Aeolo and check that returned canonicals match the routes you can serve.

## 2. Copy the shared server loader

Use **one instance per feed/key configuration**, shared by list, detail and
sitemap. Copy the following block to a server-only module, e.g.
`src/lib/aeolo-feed.server.ts` or `app/lib/aeolo-feed.server.ts`.
It requires a server runtime with native `fetch`, `Headers` and
`AbortSignal.timeout` (Node 20+ or a compatible deployment runtime).

This is the common implementation, not pseudocode: it walks every `next_url`,
checks status before JSON, bounds requests, keeps ETags **per page**, and commits
a new cache only when the entire traversal succeeds. Refresh replaces the
snapshot, so deleted/unpublished articles disappear. `since` is intentionally
unused: incremental upserts alone cannot discover removals.

```ts
// aeolo-feed.server.ts
export type Post = {
  id: string
  title: string
  date_published: string
  date_modified?: string
  content_html?: string
  content_text?: string
  summary?: string
  image?: string
  tags?: string[]
  _geo: {
    slug: string
    canonical: string
    family_id?: string
    language?: string
    schema_jsonld: Record<string, unknown>
  }
}
type Page = { version: string; items: unknown[]; next_url?: string }
type Options = {
  key: string
  origin: string
  feedUrl?: string
  fetcher?: typeof fetch
  now?: () => number
}
const FEED = 'https://api.aeolo.io/v1/connector/feed.json'
const record = (x: unknown): x is Record<string, unknown> =>
  typeof x === 'object' && x !== null && !Array.isArray(x)
const string = (x: unknown): x is string => typeof x === 'string' && x.length > 0

function validPost(x: unknown): x is Post {
  if (!record(x) || !record(x._geo)) return false
  const g = x._geo
  return string(x.id) && string(x.title) && string(x.date_published)
    && Number.isFinite(Date.parse(x.date_published))
    && (typeof x.content_html === 'string' || typeof x.content_text === 'string')
    && string(g.slug) && string(g.canonical) && record(g.schema_jsonld)
    && (g.language === undefined || string(g.language))
    && (g.family_id === undefined || string(g.family_id))
    && (x.image === undefined || typeof x.image === 'string')
    && (x.summary === undefined || typeof x.summary === 'string')
    && (x.date_modified === undefined || typeof x.date_modified === 'string')
    && (x.tags === undefined || (Array.isArray(x.tags) && x.tags.every(t => typeof t === 'string')))
}
function checkedFeedUrl(value: string): string {
  const url = new URL(value, FEED)
  if (url.origin !== 'https://api.aeolo.io' || url.pathname !== '/v1/connector/feed.json'
    || url.username || url.password || url.hash || url.searchParams.has('since')) {
    throw new Error('Invalid feed URL or pagination URL')
  }
  return url.href
}
export function createFeedClient(options: Options) {
  if (!options.key?.trim()) throw new Error('Set server-only AEOLO_KEY')
  const origin = new URL(options.origin).origin
  const firstUrl = checkedFeedUrl(options.feedUrl || FEED)
  const fetcher = options.fetcher ?? fetch
  const now = options.now ?? Date.now
  let pages = new Map<string, { etag: string | null; body: Page }>()
  let snapshot: { posts: Post[]; expires: number } | undefined
  let pending: Promise<Post[]> | undefined
  let generation = 0

  async function refresh(): Promise<Post[]> {
    const started = generation
    const nextPages = new Map<string, { etag: string | null; body: Page }>()
    const byId = new Map<string, Post>()
    let url: string | undefined = firstUrl
    for (let count = 0; url; count++) {
      url = checkedFeedUrl(url)
      if (count >= 1000 || nextPages.has(url)) throw new Error('Feed pagination cycle or limit')
      const previous = pages.get(url)
      const res = await fetcher(url, {
        headers: { Authorization: `Bearer ${options.key}`,
          ...(previous?.etag ? { 'If-None-Match': previous.etag } : {}) },
        cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(15_000),
      })
      let body: Page
      if (res.status === 304) {
        if (!previous) throw new Error('Feed 304 without cached page')
        body = previous.body
      } else {
        if (!res.ok) throw new Error(`Aeolo feed HTTP ${res.status}`)
        const value: unknown = await res.json()
        if (!record(value) || value.version !== 'https://jsonfeed.org/version/1.1'
          || !Array.isArray(value.items)
          || (value.next_url !== undefined && !string(value.next_url))) {
          throw new Error('Invalid feed envelope')
        }
        body = value as Page
      }
      nextPages.set(url, { body, etag: res.headers.get('etag') ?? (res.status === 304 ? previous?.etag ?? null : null) })
      let rejected = 0
      for (const value of body.items) {
        if (!validPost(value)) { rejected++; continue }
        let canonical: URL
        try { canonical = new URL(value._geo.canonical) }
        catch { rejected++; continue }
        if (canonical.origin !== origin || canonical.username || canonical.password
          || canonical.search || canonical.hash) {
          throw new Error('Unexpected canonical origin or address; check site/base configuration')
        }
        byId.set(value.id, value)
      }
      if (rejected) console.warn(`[aeolo] skipped ${rejected} malformed feed items`)
      if (body.items.length && rejected === body.items.length) throw new Error('Invalid feed items: entire page rejected')
      url = body.next_url
    }
    if (started !== generation) return refresh()
    const posts = [...byId.values()]
    const paths = new Map<string, string>()
    for (const post of posts) {
      const path = normalizePath(articlePath(post))
      if (paths.has(path) && paths.get(path) !== post.id) throw new Error('Duplicate canonical path; resolve slug collision')
      paths.set(path, post.id)
    }
    pages = nextPages
    snapshot = { posts, expires: now() + 60_000 }
    return posts
  }
  return {
    async getPosts(): Promise<Post[]> {
      if (snapshot && now() < snapshot.expires) return snapshot.posts
      if (!pending) pending = refresh().finally(() => { pending = undefined })
      return pending
    },
    invalidate() { generation++; snapshot = undefined; pages = new Map() },
  }
}
export function articlePath(post: Post): string {
  return new URL(post._geo.canonical).pathname
}
function normalizePath(path: string): string {
  return path.split('/').map(segment => {
    try { return encodeURIComponent(decodeURIComponent(segment).normalize('NFC')) }
    catch { return encodeURIComponent(segment.normalize('NFC')) }
  }).join('/').replace(/\/+$/, '') || '/'
}
export function findPost(posts: Post[], path: string): Post | undefined {
  return posts.find(post => normalizePath(articlePath(post)) === normalizePath(path))
}
const localeKey = (locale: string) => locale.toLowerCase()
export function servedPosts(posts: Post[], origin: string, mounts: string[], locales: string[]): Post[] {
  const allowed = new Set(locales.map(localeKey))
  return posts.filter(post => {
    const url = new URL(post._geo.canonical)
    const path = normalizePath(url.pathname)
    const parent = path.slice(0, path.lastIndexOf('/')) || '/'
    return url.origin === new URL(origin).origin
      && mounts.some(mount => normalizePath(mount) === parent)
      && allowed.has(localeKey(post._geo.language ?? 'en'))
  })
}
export function listForLocale(posts: Post[], locale: string): Post[] {
  const selected = new Map<string, Post>()
  const sorted = [...posts].sort((a, b) => Date.parse(b.date_published) - Date.parse(a.date_published) || a.id.localeCompare(b.id))
  for (const post of sorted) {
    if (localeKey(post._geo.language ?? 'en') !== localeKey(locale)) continue
    const key = post._geo.family_id ?? post.id
    if (!selected.has(key)) selected.set(key, post)
  }
  return [...selected.values()]
}
export function alternatesFor(post: Post, posts: Post[]): Record<string, string> {
  const siblings = posts.filter(p => (p._geo.family_id ?? p.id) === (post._geo.family_id ?? post.id))
  const result: Record<string, string> = {}
  for (const locale of new Set(siblings.map(p => p._geo.language ?? 'en'))) {
    const sibling = listForLocale(siblings, locale)[0]
    if (sibling) result[locale] = sibling._geo.canonical
  }
  result[post._geo.language ?? 'en'] = post._geo.canonical
  return result
}
export function jsonLd(value: unknown): string {
  return JSON.stringify(value).replace(/</g, '\\u003c')
}
export function sitemapXml(posts: Post[]): string {
  const xml = (value: string) => value.replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c]!)
  const urls = posts.map(post => {
    const modified = post.date_modified && Number.isFinite(Date.parse(post.date_modified)) ? post.date_modified : post.date_published
    const alternates = Object.entries(alternatesFor(post, posts)).map(([locale, href]) =>
      `<xhtml:link rel="alternate" hreflang="${xml(locale)}" href="${xml(href)}"/>`).join('')
    return `<url><loc>${xml(post._geo.canonical)}</loc><lastmod>${xml(modified)}</lastmod>${alternates}</url>`
  }).join('')
  return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
}
```

The cache is per server instance. It refreshes on the next request after expiry,
not on a background timer. Failures throw without replacing the last snapshot;
the example does not serve expired content on error. Return a temporary error
through the site's error boundary (prefer HTTP 503 for upstream outages), not a
successful empty list or a false 404. Existing independent CMS content can remain
available, but log/report that the Aeolo portion failed. Never log the key or the
upstream error body. Respect `Retry-After` if adding retries for 429/503; keep
retries bounded. For large catalogs, use a background full-snapshot sync and an
atomic shared store rather than extending request timeouts indefinitely.

### Configure the site's adapter once

Put this alongside the helper as `aeolo-site.server.ts`. Replace the sample
origin/mounts/locales from step 0. Import only from server code. For Next.js,
add `import 'server-only'` to both modules (install the package if absent).
For Astro, use `import.meta.env.AEOLO_KEY` / `AEOLO_FEED_URL` / `AEOLO_SITE_ORIGIN`
through its server environment support instead of assuming `process.env` is
available on every adapter. On Workers, pass server bindings into a client
scoped to that configuration.

```ts
// aeolo-site.server.ts — Node runtime example
import { createFeedClient, servedPosts } from './aeolo-feed.server'
export const ORIGIN = process.env.AEOLO_SITE_ORIGIN || 'https://yourdomain.com'
export const MOUNTS = ['/blog']
export const LOCALES = ['en', 'ko']
export const DEFAULT_LOCALE = 'en'
let client: ReturnType<typeof createFeedClient> | undefined
function feedClient() {
  return client ??= createFeedClient({
    key: process.env.AEOLO_KEY || '', origin: ORIGIN,
    feedUrl: process.env.AEOLO_FEED_URL,
  })
}
export async function getPosts() {
  return servedPosts(await feedClient().getPosts(), ORIGIN, MOUNTS, LOCALES)
}
export function invalidateFeed() { client?.invalidate() }
```

`getPosts()` returns **all served editions**, for detail lookup and sitemap.
Use `listForLocale()` only for a list page. It uses exact language tags and has
no implicit fallback: `zh-Hans` and `zh-Hant` stay distinct. If the product needs
fallback cards, label their actual language and keep their real article URL.
If `servedPosts()` returns zero from a nonempty feed, inspect mounts and language
tags before proceeding; do not treat this as successful setup.

## 3. Adapt the existing content and routing

### Preserve existing posts and list behavior

Map feed fields into the existing card shape. Use `image?.trim() || null` and
render an image only when present. Guard optional `summary` and `tags`. The feed
may contain `content_text` without `content_html`; render text as escaped text.
Unknown extension fields can be ignored. Do not assume every `_geo` field exists.

For a complete, locally available content collection: merge existing posts with
Aeolo posts by **canonical path**, keep the existing source on a collision, then
apply the site's filters, sort and paginate the combined collection. Remove
colliding Aeolo entries from detail fallback, sitemap and alternate links too.
A paginated CMS API is not a complete collection: use a combined server index or
an explicitly separate Aeolo section with its own pagination. Do not append all
Aeolo cards onto every existing page or only the first page and call pagination
complete. Check search/category behavior against the agreed integration scope.

For multiple mounts, use `servedPosts(posts, ORIGIN, ['/blog'], LOCALES)` to scope
the blog list; retain all mounts for lookup and sitemap. URL pathname, not slug
alone, identifies the route. Check existing CMS first on detail routes.

### HTML and metadata

Keep the original Markdown renderer for existing posts. For Aeolo posts, render
`content_html` as trusted feed HTML via the framework's HTML insertion mechanism;
render `content_text` in an ordinary escaped paragraph. Do not run HTML through
Markdown or convert it back to Markdown for storage. Preserve responsive images,
tables, links and embedded content. Test a long article on mobile and desktop.

If the site has a table of contents or reading time, derive it from HTML using
its existing HTML parser. Generate unique heading IDs and inject those same IDs
into the rendered headings. Do not leave a visible empty TOC or “0 min” label.
Do not invent likes, views, comments or publication dates.

For each detail response:

- Emit the title and actual body in server HTML; an H1 alone is insufficient.
- Emit exactly one canonical, equal to `_geo.canonical`.
- Serialize `_geo.schema_jsonld` with `jsonLd()` before inserting into a script.
  Escaping `<` changes serialization, not the JSON value. Avoid duplicate Article
  schemas from the old template; retain unrelated site/organization schemas.
- Set `<html lang>` to the article's `_geo.language` (default `en` only if absent).
  Pass it through the existing layout. A client-side mutation does not satisfy this.
- Use `alternatesFor(post, servedPosts)` for hreflang and language-switch links.
  This combines actual sibling canonicals across all fetched pages; the feed's
  `_geo.alternates` can describe only siblings present on that page. Never invent
  URLs from a language code. Filter to supported, actually served editions.
- A query such as `?lang=en` must not change a Korean article's canonical or body.
  A language switch should navigate to the actual sibling URL.

## 4. Wire the framework

These are route patterns using the shared modules above. Adapt imports and layout
props to the existing repository. **They do not replace steps 0–3 or the sitemap
and verification steps.** Keep server-side errors visible during integration.

### Next.js App Router (15/16, Node server)

Copy helpers to `app/lib/`. These examples use dynamic SSR and the shared 60-second
cache, so no second framework cache masks updates/removals. With Cache Components
enabled, follow that project's existing dynamic/Suspense pattern instead of
blindly adding route-segment flags. Static export needs the SSG workflow below.
The `params` prop is awaited as required by current
[Next.js dynamic routes](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes).

```tsx
// app/blog/page.tsx
import { getPosts, DEFAULT_LOCALE, ORIGIN, LOCALES } from '../lib/aeolo-site.server'
import { articlePath, listForLocale, servedPosts } from '../lib/aeolo-feed.server'
export const dynamic = 'force-dynamic'
export default async function Blog() {
  const posts = listForLocale(servedPosts(await getPosts(), ORIGIN, ['/blog'], LOCALES), DEFAULT_LOCALE)
  return <ul>{posts.map(post => <li key={post.id}>
    <a href={articlePath(post)}>{post.title}</a>
  </li>)}</ul>
}
```

On a localized list route, pass its validated locale instead of `DEFAULT_LOCALE`
and its actual mount instead of `/blog`.

```tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPosts } from '../../lib/aeolo-site.server'
import { findPost, jsonLd, alternatesFor } from '../../lib/aeolo-feed.server'
export const dynamic = 'force-dynamic'
type Props = { params: Promise<{ slug: string }> }
async function readPost(params: Props['params']) {
  const { slug } = await params
  const posts = await getPosts()
  const post = findPost(posts, `/blog/${encodeURIComponent(slug)}`)
  if (!post) notFound()
  return { post, posts }
}
export async function generateMetadata({ params }: Props) {
  const { post, posts } = await readPost(params)
  return { title: post.title, description: post.summary,
    alternates: { canonical: post._geo.canonical, languages: alternatesFor(post, posts) } }
}
export default async function Article({ params }: Props) {
  const { post } = await readPost(params)
  return <article lang={post._geo.language ?? 'en'}>
    <h1>{post.title}</h1>
    <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(post._geo.schema_jsonld) }} />
    {post.content_html !== undefined
      ? <div dangerouslySetInnerHTML={{ __html: post.content_html }} />
      : <p>{post.content_text}</p>}
  </article>
}
```

The example's `<article lang>` does not set `<html lang>`. Connect the edition
language to the site's root document strategy (for example its existing locale
root layout). If that cannot match these URLs, resolve locale routing and Aeolo
addresses before shipping. Do not create a nested second `<html>` in a page.

### React Router v7 framework mode / Remix SSR

Keep helpers in `app/lib/`. Register routes explicitly in React Router v7's
`app/routes.ts`; filenames alone do not register routes unless the project uses
a filesystem-route adapter. Keep existing entries:

```ts
// app/routes.ts — merge these entries into the existing configuration
import { route, type RouteConfig } from '@react-router/dev/routes'
export default [
  // Keep the site's other routes here.
  route('blog', 'routes/blog.tsx'),
  route('blog/:slug', 'routes/blog.$slug.tsx'),
  route('sitemap-aeolo.xml', 'routes/sitemap-aeolo.ts'),
] satisfies RouteConfig
```

Use the site's existing list component. Its server loader returns cards from
`listForLocale(await getPosts(), locale)` with `articlePath(post)` links; pass
only the fields required by the UI. Never use `clientLoader` for this feed.

```tsx
// app/routes/blog.$slug.tsx
import { useLoaderData, type LoaderFunctionArgs, type MetaFunction } from 'react-router'
import { getPosts } from '../lib/aeolo-site.server'
import { findPost, jsonLd, alternatesFor } from '../lib/aeolo-feed.server'
export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await getPosts()
  const post = findPost(posts, new URL(request.url).pathname)
  if (!post) throw new Response('Not found', { status: 404 })
  return { post, schema: jsonLd(post._geo.schema_jsonld), alternates: alternatesFor(post, posts) }
}
export const meta: MetaFunction<typeof loader> = ({ data }) => data ? [
  { title: data.post.title },
  { tagName: 'link', rel: 'canonical', href: data.post._geo.canonical },
  ...Object.entries(data.alternates).map(([hrefLang, href]) => ({ tagName: 'link', rel: 'alternate', hrefLang, href })),
] : []
export default function Article() {
  const { post, schema } = useLoaderData<typeof loader>()
  return <article>
    <h1>{post.title}</h1>
    <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: schema }} />
    {post.content_html !== undefined
      ? <div dangerouslySetInnerHTML={{ __html: post.content_html }} />
      : <p>{post.content_text}</p>}
  </article>
}
```

In the root document use the matched article loader's `post._geo.language` for
`<html lang>` through `useMatches()`, falling back to the existing site locale.
Keep `<Meta />` and the ownership tag in the head. Do not import server helpers
into client rendering code: calculate the schema/links in the loader as above.
Remix uses its existing route convention and `@remix-run/node` /
`@remix-run/react` imports. See the [route module contract](https://reactrouter.com/start/framework/route-module).

### Astro SSR

Keep helpers in `src/lib/`, use server env access for your adapter, and install
or reuse the deployment adapter. Enable on-demand rendering on the list, detail
and sitemap routes with `export const prerender = false`, or use the project's
existing `output: 'server'`. See [Astro rendering modes](https://docs.astro.build/en/guides/on-demand-rendering/).

```astro
---
// src/pages/blog/[slug].astro
export const prerender = false
import { getPosts } from '../../lib/aeolo-site.server'
import { findPost, jsonLd, alternatesFor } from '../../lib/aeolo-feed.server'
const posts = await getPosts()
const post = findPost(posts, Astro.url.pathname)
if (!post) return new Response('Not found', { status: 404 })
const alternates = alternatesFor(post, posts)
---
<!-- For a new site only. In an existing site pass these head/body values to its layout. -->
<html lang={post._geo.language ?? 'en'}>
  <head>
    <title>{post.title}</title>
    <link rel="canonical" href={post._geo.canonical} />
    {Object.entries(alternates).map(([locale, href]) => <link rel="alternate" hreflang={locale} href={href} />)}
    <script type="application/ld+json" set:html={jsonLd(post._geo.schema_jsonld)} />
  </head>
  <body><article>
    <h1>{post.title}</h1>
    {post.content_html !== undefined ? <div set:html={post.content_html} /> : <p>{post.content_text}</p>}
  </article></body>
</html>
```

Create/adapt `src/pages/blog/index.astro` using the same `getPosts()` and
`listForLocale()`/`articlePath()` functions. Pass metadata and ownership tag into
the shared layout; do not duplicate a full HTML document inside it.

### Static builds (Astro SSG, Next static export, other generators)

A 60-second process cache cannot refresh deployed static files. Fetch one complete
snapshot during the build, use it for all routes and sitemap, and fail the build
if the feed cannot be fetched. For Astro, use `getStaticPaths()` (no
`prerender = false`); for Next use the installed version's static export and
`generateStaticParams()` contract. For a `/blog/[slug]` route enumerate only items
whose canonical parent is `/blog` and pass each decoded slug to the generator.
Generate each supported locale's actual URL, not an invented prefix.

Configure a deployment hook or a scheduled full rebuild, document the expected
maximum delay, and test it. A rebuild must replace removed routes, not leave old
HTML files in the output. A static host alone cannot implement a runtime webhook;
use its deploy-hook mechanism. A failed rebuild leaves the previous deployment
live, including withdrawn posts: alert on failure and establish how urgent
removals purge the old deployment/CDN. Never claim 60-second freshness for SSG
without a measured mechanism that achieves it.

## 5. Add the sitemap and refresh behavior

Use the same **all-editions, supported-routes** `getPosts()` for the sitemap. Do
not use the list's language/family deduplication. Include actual canonicals and
`date_modified` (fall back to `date_published`), not today's build timestamp.
Merge with existing sitemap entries or expose a separate sitemap and add it to
the existing sitemap index or a `Sitemap:` entry in `robots.txt`. Preserve existing
sitemap discovery. Partition sitemaps if reaching the standard size limits.

Example separate Next.js route:

```ts
// app/sitemap-aeolo.xml/route.ts
import { getPosts } from '../lib/aeolo-site.server'
import { sitemapXml } from '../lib/aeolo-feed.server'
export const dynamic = 'force-dynamic'
export async function GET() {
  return new Response(sitemapXml(await getPosts()), {
    headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'no-store' },
  })
}
```

React Router: put the same response in a `loader` in the registered
`app/routes/sitemap-aeolo.ts`, using `../lib/` imports. Astro: put it in `GET` in
`src/pages/sitemap-aeolo.xml.ts`, import from `../lib/`, and set `prerender = false`
for SSR. SSG: write this XML to the build output from the same build snapshot.

### Runtime caching and optional invalidation

The helper caches the whole feed for 60 seconds and revalidates each cached page
with its ETag. A 304 has no JSON body. Do not assume a framework/CDN automatically
sends conditional headers; the helper explicitly does so. Do not place a longer
HTML, ISR or CDN cache in front of it during initial verification.

An optional authenticated endpoint can call `invalidateFeed()`. Use the exact
`revalidateUrl` and `revalidateSecret` supplied by the connection. Require a
configured, nonempty secret before comparing the Authorization header. Never
accept `Bearer undefined` when the environment variable is missing. The feed
read key and webhook secret are different credentials.

Aeolo's notification is best-effort; keep TTL/rebuild recovery. An in-process
invalidation clears only that instance. Multi-instance/serverless deployments
need shared invalidation or must honestly retain the TTL delay. Test an actual
withdrawal through a different instance before claiming immediate takedown.
If adopting Next's tagged shared cache instead, invalidate the whole snapshot
and the affected rendered routes. Current Next webhooks can use
`revalidateTag('aeolo-feed', { expire: 0 })`; see
[the framework's invalidation contract](https://nextjs.org/docs/app/api-reference/functions/revalidateTag).
Do not add that call to the process-cache example and expect it to clear that cache.

## 6. Optional: serve images from your own host

Start with the feed's image URLs. If first-party image URLs are required, add
`assets=first-party` to the feed URL and keep the pagination URLs unchanged.
Check `feed._geo.asset_mode === 'first_party'`; absence means render URLs as given.

**Extend the shared snapshot before enabling this option.** The starter helper
retains post fields but does not expose the feed-level asset manifest. Collect
both feed-level and item-level `_geo.assets: [{ path, source }]` across every page,
including cached 304 pages, and commit their lookup map with the same snapshot.
Body, image and JSON-LD references may already use local paths in this mode.

For SSR, implement each supplied `_assets` path as an allowlisted lookup in that
map. Unknown paths return 404. Fetch only manifest sources on
`https://cdn.aeolo.io/`, server-side, with redirects refused, a timeout, and a fixed
`Accept: image/avif,image/webp,image/*`. Return the image content type and bytes;
use a long immutable cache only when the source declares immutable. Failed source
requests return an error, never a redirect to the source. Do not expose manifest
sources in rendered HTML. SSG downloads the assets to exactly those output paths
before deployment. Check body, cover, JSON-LD and CSS image references as well as
the asset route; don't enable this mode with an unimplemented image proxy.

## 7. Verification: finish with evidence

Run the project's build/typecheck and targeted tests, then serve its production
build locally. Use HTTP requests **without JavaScript** and an HTML/XML parser
(attribute order, encoding and minified markup make grep-only checks unreliable).
Also inspect desktop/mobile rendering in a browser.

Create deterministic fixtures if the live feed is small. Fixture tests are not
live verification. At minimum exercise:

| Case | Required result |
|---|---|
| 51+ items over multiple pages | Last item has a list link, detail route and sitemap entry |
| Missing/invalid key; upstream 429/500; failure on page 2 | Explicit error, no successful empty or partial snapshot |
| One malformed item; text-only body; empty image | Valid siblings remain; escaped text; no `img src=""` |
| Multiple languages and repeated family editions | One current family member per locale list; every served edition resolves |
| `zh-Hans` vs `zh-Hant` | Distinct selection and hreflang |
| Non-ASCII/NFC/NFD path; identical slug on two mounts | Correct article, no cross-mount match |
| Empty successful feed; unknown slug | Valid empty state; unknown detail returns HTTP 404 |
| Full snapshot refresh after update/removal | Updated body; removed detail 404 and absent from list/sitemap |
| Existing CMS path collision | Existing post wins in detail, lists, sitemap and alternates |

For real-site verification, choose an existing published item destined for this
site. Let `path = new URL(item._geo.canonical).pathname`; request that path on the
local/preview origin, preserving the production canonical in the HTML.
Record the item ID, URL, response status and checks without credentials:

1. **Ownership:** supplied tag appears in raw HTML on the origin and configured
   mount; Aeolo reports verified. This proves ownership only.
2. **Feed:** successful authenticated response, page count and total unique item
   count; item belongs to the intended destination. Zero items means live article
   rendering remains unverified, not that integration is complete.
3. **List:** intended locale/mount links to the item; existing posts, pagination,
   search and category behavior remain correct. Count cards, not every repeated
   link in header/footer markup.
4. **Detail:** HTTP 200; a distinctive paragraph from the expected body appears as
   rendered content in server HTML, not only in a hydration script; title, cover,
   tables and links display correctly. Unknown slug returns actual HTTP 404.
5. **Metadata:** exactly one canonical matching the feed; parsed Article JSON-LD
   retains supplied fields; document language matches body; every advertised
   alternate is a real served sibling with the advertised language. Self-hreflang
   is valid. Query strings do not alter the canonical.
6. **Sitemap:** discoverable from existing index/robots; valid XML containing the
   exact canonical and source lastmod; every listed URL is a served edition.
7. **Lifecycle:** using an authorized disposable article, test publish → edit and
   republish → unpublish. Wait out the **entire** documented cache/build delay,
   then recheck list, detail, sitemap and any CDN. Do not unpublish a customer's
   real article merely to test the integration. Without a disposable item or
   permission, run fixtures and mark the live lifecycle check pending.
8. **Production:** after authorized deployment, repeat these checks against the
   actual public origin. A local build cannot prove hosting env, adapter, routing,
   scheduled builds or CDN invalidation.

Indexing and citations are separate outcomes. Do not hold a correct integration
open waiting for Google, or claim indexing just because the sitemap works.

### Troubleshooting

| Symptom | Check / next action |
|---|---|
| 400 `TOKEN_NOT_CHANNEL_BOUND` | Issue a read key bound to this custom channel; don't add a domain ID to the URL |
| 401 / 403 | Key present in server runtime, current and permitted for this brand; never print it |
| 404 from feed | Exact endpoint and existing channel; retired per-domain feed routes are unsupported |
| 200 with no items | Published status and correct site destination in Aeolo; drafts aren't returned |
| Feed items exist, site has none | Server fetch error, mount/language filter, fallback merge or HTML/Markdown mismatch |
| Canonical origin/address error | Production origin, `www`, channel destinations and deliberate `base` override |
| Only newest 50 work | An old route still fetches one page instead of sharing `getPosts()` |
| Local works, production 404 | Runtime env, route registration, SSR adapter or missing SSG path/rebuild |
| Verification fails | Raw deployed head, root fallback, redirects, wrong token, auth/WAF blocking requests |
| New/removed post stays wrong | Process TTL plus HTML/ISR/CDN cache, static rebuild result, webhook instance scope |
| Images fail after first-party opt-in | Asset manifests from every page, lookup route/output files and allowed source response |

### Required completion report

Return a short report with:

- Phase A–G status, initial/final audit scores with scope and dates, brand brief,
  Topic/page map and technical fixes.
- GA4 collection proof, selected numeric property ID, GSC ownership/property,
  and Aeolo readback; no secret values.
- Indexing inventory: prepared/submitted/indexed states and actual receipts;
  AI citations/referrals/key events only when separately observed.
- Changed files and the existing components/routes reused.
- Framework/version, rendering mode, origin, mounts, languages and secret variable
  **names only**.
- Tests/build results, including failing checks and any pre-existing failures.
- Local and production URLs actually checked, feed item/page counts, and each
  verification outcome: **passed / failed / pending**, with the reason.
- Measured refresh/removal delay and the configured mechanism.
- Any exact owner action remaining (configure env, verify tag, publish a test
  article, authorize deployment), plus whether code is local, deployed, or live
  verified. Do not label pending production/lifecycle checks “done”.
