Skip to content

The package

Three entry points matter to an Astro app: @geoffy/headless/astro for the framework adapters, @geoffy/headless for the framework-agnostic client underneath them, and @geoffy/headless/astro-middleware for the optional AI visit tracking.

interface GeoffyClientOptions {
siteKey: string; // from Geoffy settings; public, addresses rather than authorises
origin?: string; // overrides GEOFFY_ORIGIN and the default, for this call
timeoutMs?: number; // how long before giving up and rendering without Geoffy
revalidateSeconds?: number; // Next.js only; accepted and ignored in Astro
}

timeoutMs is deliberately short: the fallback (render without the widget) is cheap, while a stalled build is not.

A missing or empty siteKey, handle or file name makes no request. The helper returns “no content” as if nothing were published, and the server log says so once:

@geoffy/headless: GEOFFY_SITE_KEY is not set, so Geoffy content is skipped. Set it to the site key from your Geoffy settings.

getGeoffyProductMarkup(options, handle, extra?)

Section titled “getGeoffyProductMarkup(options, handle, extra?)”
const geoffy = await getGeoffyProductMarkup(
{ siteKey },
handle,
{ canonicalUrl, locale },
);

extra takes canonicalUrl and locale, both optional. locale is the language this page renders, for example sr or de-DE. Only the language part is compared, so en-GB content matches an en-US page. A value that is not a language tag is treated as unknown, and the helper returns the content.

Resolves to null, or to:

interface GeoffyProductMarkup {
jsonLdScript: string; // a complete <script type="application/ld+json"> element
widgetHtml: string; // the visible widget, styles included
artifact: GeoffyProductArtifact;
}

Both strings are pre-rendered HTML, so both need set:html. null covers every case (nothing published, Geoffy unreachable, an error, a locale in a different language from the published content, or a canonicalUrl that does not match), so your {geoffy && …} guards handle every one of them.

artifact is the raw data behind the two strings, if you want to render your own markup instead.

Pages with nothing to show cost no request

Section titled “Pages with nothing to show cost no request”

The helper reads a small list of the products Geoffy has published, with the language and the path of each, and keeps it in memory for 30 seconds. With that list, it returns null for an unpublished product, a page in another language, or a page whose path is not the published one, without asking Geoffy about the product. A newly published product appears within those 30 seconds, or at your next build on a static site. There is nothing to configure.

If the list cannot be read, the helper asks about the product directly, as older versions do. A problem on Geoffy’s side never hides a published product.

createGeoffyTextEndpoint(options, name, endpoint?)

Section titled “createGeoffyTextEndpoint(options, name, endpoint?)”
export const GET = createGeoffyTextEndpoint({ siteKey }, "llms.txt");

name is "llms.txt", "llms-full.txt" or "agents.md". It refuses "robots-rules.txt": that is a fragment you append to your own file, not a whole file to serve. Answers 503 with Retry-After when Geoffy is degraded, never a fabricated 404.

endpoint takes one option, forwardCrawler, off by default. When it is on, the endpoint tells Geoffy which AI agent asked for the file, by its agent name and never a raw user agent. It has an effect only on an endpoint rendered on request (export const prerender = false): a prerendered file has no request to read.

export const prerender = false;
export const GET = createGeoffyProxyEndpoint({ siteKey });

The /apps/geoffy/[...path] catch-all. prerender = false is required: without it astro build fails outright. Query strings are not forwarded. It tells Geoffy which AI agent asked for a file, by the agent’s name only; a browser’s user agent is never passed on.

src/middleware.ts
export const onRequest = createGeoffyAiVisitMiddleware({
siteKey: import.meta.env.GEOFFY_SITE_KEY,
secret: import.meta.env.GEOFFY_REVALIDATE_SECRET,
});

Optional. Reports the AI crawlers that read your pages and the shoppers who arrive from an AI assistant; every other request makes no network call. Returns an onRequest handler that calls next() once and returns its result. The report is handed to context.waitUntil, or to locals.runtime.ctx.waitUntil, when your adapter provides one. Pages prerendered at build time never reach middleware, so they are not counted.

Takes siteKey, secret (default GEOFFY_REVALIDATE_SECRET from process.env), origin, clientIp (which here also receives the middleware context), exclude, sample and timeoutMs. With no secret, nothing is sent. AI visit tracking has the table and what each report carries.

Also exported: AI_ASSISTANT_HOSTS and BUILT_IN_AI_AGENT_SIGNATURES, the lists it matches against.

The framework-agnostic client. You will use fetchGeoffyText for robots.txt; the rest is what another framework’s adapter would be built on.

ExportSignatureNotes
fetchGeoffyText(options, name, request?) => Promise<string | null>null means unreachable. Accepts "robots-rules.txt", which the endpoint factory does not. Pass request to tell Geoffy which AI agent asked
fetchGeoffyProduct(options, handle) => Promise<GeoffyProductArtifact | null>The raw artifact. null also when nothing is published for the handle
serializeJsonLd(node: unknown) => stringEscapes < safely for inline script
compareCanonical(page, artifact) => "match" | "mismatch" | "unknown"Path only, not origin
fetchGeoffyManifest(options, memoMs?) => Promise<GeoffyManifest | null>The list of published products, with the language and path of each. null on any failure
decideFromManifest(manifest, handle, page?) => ManifestDecisionWhether a page should ask for a product, from the manifest alone. reason is "not-published", "language-mismatch" or "canonical-mismatch"
handleGeoffyProxy(options, request) => Promise<Response>What the proxy endpoint wraps. Takes the Request, not its URL
resolveProxyPath(requestUrl) => string | nullnull for anything outside the namespace
DEFAULT_GEOFFY_ORIGIN"https://api.geoffy.ai"
PROXY_PREFIX"/apps/geoffy"The mount path is fixed; see the namespace
interface GeoffyProductArtifact {
handle: string;
canonicalUrl: string; // the URL Geoffy published against
jsonLd: unknown; // schema.org Product node
widgetHtml: string; // styles included — do not sanitise them out
publishedAt: string | null;
}

When your site is verified but nothing is published for a handle, Geoffy answers with a successful { "handle": "blue-widget", "published": false }, not an error. fetchGeoffyProduct returns null for it, so your guards render nothing.

Node 18 or newer: the package uses global fetch, AbortController and crypto.subtle. ESM only. React is an optional peer dependency and is not needed for the /astro entry point.