AIDP 0.3 publishing channel for Astro 5.
An Astro package that turns your site into a first-class AIDP source: publishes the entity directive at /.well-known/aidp.json, exposes signed content endpoints + a paginated content directory, injects <link rel="aidp"> head tags, receives cache-invalidation webhooks, and observes AI-crawler traffic for upload to your dashboard.
Feature-equivalent to @speakspec/nuxt and @speakspec/next.
pnpm add @speakspec/astro# .env
SPEAKSPEC_ENTITY_ID=your-entity-slug
SPEAKSPEC_API_KEY=aidp_xxxxxxxxxxx
SPEAKSPEC_WEBHOOK_SECRET=...
PUBLIC_SPEAKSPEC_SITE_ORIGIN=https://yoursite.com
SPEAKSPEC_BOT_TRACKING=true
SPEAKSPEC_BOT_UPLOAD=trueAstro requires output: 'server' (or output: 'hybrid') to serve dynamic API routes. Add one route file per AIDP endpoint:
// src/pages/.well-known/aidp.json.ts
import { aidpEntityRoute } from '@speakspec/astro'
export const GET = aidpEntityRoute()// src/pages/.well-known/aidp/content/[id].json.ts
import { aidpContentRoute } from '@speakspec/astro'
export const GET = aidpContentRoute()// src/pages/.well-known/aidp/content/index.ts
import { aidpDirectoryRoute } from '@speakspec/astro'
export const GET = aidpDirectoryRoute()// src/pages/api/aidp/invalidate.ts ← NO leading underscore
import { aidpWebhookRoute } from '@speakspec/astro'
export const POST = aidpWebhookRoute()Astro 5 excludes any path segment starting with
_from routing (treats it as private). Useapi/aidp/...(no leading underscore). The path you register with the SpeakSpec dashboard must match.
// src/pages/llms.txt.ts (optional — serves spec §11.3 llms.txt projection)
import { llmsTxtRoute } from '@speakspec/astro'
export const GET = llmsTxtRoute()AIDP v0.4 introduces per-type content strategy. The entity owner can decide, per content type, whether content appears:
- Inline (
inline, default): full content envelopes appear inside/.well-known/aidp.json'scontentarray - Directory (
directory): the type is omitted fromaidp.json.content; AI agents fetch/.well-known/aidp/content/directory.jsonfor the index, and/.well-known/aidp/content/{id}.jsonfor individual envelopes
The content_index field in aidp.json declares which types are inlined vs indexed:
{
"content_index": {
"url": "https://example.com/.well-known/aidp/content/directory.json",
"types_inlined": ["faq", "service"],
"types_indexed": ["article", "event"],
"total_by_type": { "article": 1240, "event": 387, "faq": 18, "service": 6 },
"pinned_count": 3,
"updated_at": "2026-05-12T10:00:00Z"
}
}The SDK proxies the upstream response transparently—no client code change is needed when an entity owner switches strategy. AI consumers should check content_index.types_indexed and pull directory.json when needed.
Any content can be marked pinned: true. Pinned content always appears in aidp.json.content regardless of the type's strategy, sorted first.
// src/middleware.ts
import { aidpBotMiddleware } from '@speakspec/astro/middleware'
export const onRequest = aidpBotMiddleware()If you already have middleware, sequence them:
import { sequence } from 'astro:middleware'
import { aidpBotMiddleware } from '@speakspec/astro/middleware'
export const onRequest = sequence(myExisting, aidpBotMiddleware())---
// src/layouts/BaseLayout.astro
import AidpLinks from '@speakspec/astro/components/AidpLinks.astro'
---
<html>
<head>
<AidpLinks />
</head>
<body><slot /></body>
</html>For per-page binding on article / product / policy pages:
---
// src/pages/articles/[id].astro
import AidpContent from '@speakspec/astro/components/AidpContent.astro'
const article = await loadArticle(Astro.params.id)
---
<AidpContent contentId={article.id} pathname={`/articles/${article.id}`} />
<article set:html={article.body} /><AidpContent /> registers the (path → content_id) mapping with the SDK so subsequent AI crawler hits get enriched with content_id.
Default in-memory cache. Plug in Redis / fs / etc. at boot:
// src/server-init.ts (called from astro:server:setup integration)
import { setCacheStore } from '@speakspec/astro'
import { redisStore } from './my-cache'
setCacheStore(redisStore)Any object satisfying:
interface FullStore {
getItem<T>(key: string): Promise<T | null>
setItem(key: string, value: unknown): Promise<void>
removeItem(key: string): Promise<void>
getKeys(base: string): Promise<string[]>
}works.
The SDK serves three well-known routes with Cache-Control headers
tuned for fast revocation propagation. If you have Cloudflare /
CloudFront in front of your site, those headers are what the CDN
respects — so they directly bound how long it takes a revoked fact
to disappear from AI agent answers.
There are two TTLs to think about:
| Layer | What it does | Default | Affects |
|---|---|---|---|
| SDK internal | how long the SDK process reuses a fetched bundle before re-fetching from SpeakSpec | 300s | origin load on SpeakSpec |
Cache-Control: max-age |
how long downstream caches (CDN + AI agents) reuse the response | 60s (entity/directory), 300s (content) | revocation propagation, CDN cost |
Why entity = 60s but content = 300s by default? The entity directive (/.well-known/aidp.json) is the revocation pivot — when a customer revokes a fact, this is the document AI agents re-fetch first to learn what's still valid. Short max-age keeps revocation fast. Per-content envelopes (/.well-known/aidp/content/[id].json) are content-addressed: each updated_at produces a new signed bundle, so longer caching is safe.
Setting max-age=0 disables CDN caching for that route but does NOT disable stale-while-revalidate — the CDN still serves stale within the SWR window while it revalidates. To fully disable caching, set both *_MAX_AGE=0 and *_SWR=0.
The SDK internal TTL is mostly the safety net for missed webhooks —
when an entity is revoked, SpeakSpec sends a webhook that clears the
SDK cache instantly. Downstream max-age is the real ceiling on how
quickly AI agents see the revocation.
All values are configurable via env vars (seconds):
# SDK internal cache (default 300)
SPEAKSPEC_CACHE_TTL_SEC=300
# /.well-known/aidp.json (default 60 / 300)
SPEAKSPEC_ENTITY_MAX_AGE=60
SPEAKSPEC_ENTITY_SWR=300
# /.well-known/aidp/content/[id] (default 300 / 600)
SPEAKSPEC_CONTENT_MAX_AGE=300
SPEAKSPEC_CONTENT_SWR=600
# /.well-known/aidp/content (default 60 / 300)
SPEAKSPEC_DIRECTORY_MAX_AGE=60
SPEAKSPEC_DIRECTORY_SWR=300Trade-off: longer max-age means lower origin/CDN bill but
slower revocation. Worst-case revocation propagation is bounded by
max-age + stale-while-revalidate. If you want sub-minute revocation
across Cloudflare, also wire SpeakSpec's webhook to a Cloudflare
purge — out of SDK scope.
- Output mode: requires Astro
output: 'server'or'hybrid'for API routes to be dynamic.output: 'static'(the default) bakes all routes at build time and won't update directives without a rebuild. - Multi-instance: in-memory cache + impression queue are per-process. Customers running on Cloudflare or similar edge platforms should provide a Redis-backed cache via
setCacheStore. - First-hit content_id:
<AidpContent />registers on render, so the very first AI crawler hit on a path lands withcontent_id=null. Subsequent hits are enriched.
MIT