Repository navigation
Conversation
…command Portals can offer an Ask AI chat panel, set up by `portal.ai.copilot` in apimatic.json (public key, service address, starter questions). The new `apimatic portal copilot sync` command uploads a generated portal's pages to the Ask AI service and waits until they are indexed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lint Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es not trust `apiUrl` must be HTTPS, except for a service on this machine. `portal copilot sync` sends the saved login only to APIMatic's own services or one on this machine; any other needs the key passed with --auth-key. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… pages - Name the service "Ask AI" in every message, and explain in the help when sync needs --auth-key. - Move the chat's link rewriting into its own module, leave full addresses alone, and test it under a base path. - Drop CopilotBundle.toJSON, which only tests used, and keep the linked dependencies in order. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When `ai.copilot` is set, `portal generate` writes `__copilot/bundle.json` and `__copilot/build.json` into the portal, so the Ask AI service can refresh itself from the deployed site instead of waiting for `portal copilot sync`. The build ID is the sha256 of the bundle's bytes. The bundle is written before the portal is saved, so zipped portals carry it too. If it cannot be written, generate warns and points to `sync`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eat/portal-ai-copilot
…another block The merge of dev dropped the end of the merged-spec-folders suite and the start of the Ask AI one, so the chat's checks ran against a portal that offers no chat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ack on the description Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # pnpm-lock.yaml
There was a problem hiding this comment.
The opt-in block, the lazy chat chunk and the HTTPS-only service address are good. Requesting changes for the items under "Must fix".
Must fix
- An empty
--auth-keysends the saved APIMatic login to anyapiUrl. - Under a sub-path (
site.url=https://x.com/docs), the chat's source links go to/docs/docs/…and 404. - Pages with non-ASCII names are silently left out of the bundle.
- A user's own
static/llms.txtdecides what Ask AI sees. With full URLs, it sees nothing. - Portals without the chat still change. Measured on the default fixture against
dev:- CSS: +11.7 KB raw, of which the chat's
@sourceis 7.8 KB. - JS loaded up front: +18.3 KB gzipped, of which the Fumadocs upgrade is 13.1 KB.
- The 60 KB chat chunk ships in every portal's output.
- CSS: +11.7 KB raw, of which the chat's
- Merge order with #437, which also bumps Fumadocs, lucide-react and shiki.
Should fix
- The build ID changes on every
generate. - An error while bundling stops
generatebefore the portal is saved. synccan't read a zipped portal.- Timeouts are reported as "could not be reached".
generateandsync --helpgive different advice on when to sync.*.localhostis trusted as loopback.- Every chat message sends the visitor's full page URL.
Design
- The page list is parsed from
llms.txtinstead of coming from the template. - The poll loop is a copy of
pollUntilCompleted. - The service address is kept as a string.
- The saved-login rule is split across two layers.
- Ask AI reads each operation page's Markdown (the
METHOD /pathline and the```yamlblock, code samples included), so the layoutllms.server.tswrites is now part of the Ask AI contract. It isn't in the description's contract table and no test pins it. A change to that renderer, such as moving the samples out of the YAML, would quietly stop parameters and samples being indexed. Pin it with a test, or send structured fields in the bundle.
Also: portals with the chat hydrate about 0.5 s later on Slow 4G (3.58 s vs 3.08 s), and the feature is "Ask AI" to readers but copilot to authors.
The 136 unit tests this PR touches pass on this head, and CI is green.
| return { ...page, markdown: await this.fileService.getContents(markdown) }; | ||
| } | ||
|
|
||
| private markdownOf({ path }: ListedPage): FilePath { |
There was a problem hiding this comment.
llms.txt lists encoded paths (/guides/caf%C3%A9), but the .md files are written decoded (guides/café.md). This lookup misses them and drops the page without a warning. In a test build, 7 pages were listed and 5 bundled.
There was a problem hiding this comment.
Fixed in a2ce8488. The CLI no longer maps llms.txt links to .md files.
The template now writes the pages itself from source.getPages(), each with its path as the router links it. The copilot e2e fixture has a café page and a page the navigation leaves out. The test now checks that the bundle holds exactly the pages the build wrote Markdown copies for, no more and no fewer.
| description: string | null; | ||
| } | ||
|
|
||
| const LINK = /^\s*[-*]\s+\[(.*)\]\(([^)\s]+)\)(?::(.*)|\s*)$/; |
There was a problem hiding this comment.
Parsing llms.txt (a file written for LLMs) and rebuilding the page-to-file rule here duplicates what the template already knows. It breaks in several ways:
- encoded paths (see the comment above);
- a user's own
static/llms.txtreplaces the file; syncuses today's base path, not the one the portal was built with;- Fumadocs escapes
(,),[and], so some links don't match; - pages hidden from the navigation are never listed.
Suggestion: have the template emit the bundle as a prerendered route next to llms.txt. generate then gets it from the build, sync reads one file, and this file, markdownOf and most of GeneratedPortalContext go away.
There was a problem hiding this comment.
Done along the lines you suggested, in a2ce8488. llms-index.ts, markdownOf and the file lookup are gone.
One difference: the template prerenders the page list (/api/copilot-pages.json) rather than the finished bundle. generate wraps it into __copilot/bundle.json and build.json, so the bundle format and the build ID stay in the CLI, next to sync, which uses them. The page list is a build-only file that generate removes, so it is never deployed (1af59d49).
What this fixes, point by point:
- Encoded paths: pages come from the source, not from links.
- A user's
static/llms.txt: no longer read. syncand today's base path:syncuploads the bundlegeneratewrote, under the base path it was built with.- Escaping: links are no longer parsed.
- Hidden pages: included.
| ); | ||
| } | ||
|
|
||
| public copilotPagesNotFound() { |
There was a problem hiding this comment.
This is reachable when a user's static/llms.txt uses full URLs (0 pages). It then tells them to run sync, which reads the same files and fails the same way. Say what to check instead.
There was a problem hiding this comment.
Fixed. llms.txt no longer decides the pages, so the case you describe cannot happen, and the message no longer points to sync (a2ce8488).
What is left are two different situations, each with its own message (cc8bffca):
- The portal has no pages: the message says so, without suggesting
sync. - The build wrote no page file at all: the message says to generate again, and whom to contact if the pages are still missing, rather than sending the author through their docs.
| @import 'fumadocs-ui/css/neutral.css'; | ||
| @import 'fumadocs-ui/css/preset.css'; | ||
| @import 'fumadocs-openapi/css/preset.css'; | ||
| @import '@fumadocs/ai-chat/css/preset.css'; |
There was a problem hiding this comment.
This file is @source '../dist/**/*.js', so every portal's CSS gets the chat's classes (+7.8 KB raw), whether it has the chat or not.
There was a problem hiding this comment.
Fixed in bacb58ce.
app.css no longer imports the chat's preset. The CLI writes it into theme.css only for a portal with ai.copilot, and portal serve rewrites it when the block changes.
| log.error(message); | ||
| } | ||
|
|
||
| public portalNotGenerated(portalDirectory: DirectoryPath) { |
There was a problem hiding this comment.
A portal generated with --zip holds only portal.zip, so this tells the user to generate a portal they just generated. Read the zip, or say that zipped portals aren't supported.
There was a problem hiding this comment.
Fixed in 26cf47ca.
When the folder has no __copilot/bundle.json, sync reads the same path inside portal.zip, through a new ZipService.readEntry that reads one file without unpacking the rest. A test syncs a folder that holds only portal.zip.
| } | ||
|
|
||
| // Every request answers with its status, so an axios error here is one that never got an answer. | ||
| function transportFailure(error: unknown): ServiceError { |
There was a problem hiding this comment.
Timeouts land here as network errors, so a slow upload or status check reports "could not be reached. Check the address".
There was a problem hiding this comment.
Fixed in fe50480e:
- A timeout is now recognised as one. The client uses axios's
clarifyTimeoutError, so a request that runs out of time fails asETIMEDOUT. - It is reported as a timeout, naming how long the CLI waited.
- The advice depends on the step:
- after an upload: sync again, which reports "nothing changed" if the pages arrived;
- while indexing: check back later.
| * The service links pages by their path within the portal; the host serves them under the base | ||
| * path. A full address already says where it is, so it is left alone. | ||
| */ | ||
| export function servedUrl(url: string, base?: string): string { |
There was a problem hiding this comment.
This doubles the base path. The chat renders Fumadocs' Link, which becomes <Link to={href}>, and the router adds its basepath again. With basepath: '/docs', to="/docs/guides/intro" renders /docs/docs/guides/intro. Every source link then 404s, and the conversation is lost. Pass the service's paths through unchanged, and add a test under a sub-path.
There was a problem hiding this comment.
Fixed in a647b1af.
copilot-links.ts is gone, and the service's paths go through unchanged, so the router adds the base path once.
test/portal-template/copilot-chat-links.test.ts (renamed in 984940b9, which also dropped a check that matched the chat package's minified code) renders the chat's sources in a TanStack router with basepath: '/docs'. It checks that /guides/intro becomes /docs/guides/intro, and that a full address is left alone.
| /** The portal still saves without the bundle; Ask AI is then updated by `portal copilot sync`. */ | ||
| private async publishCopilotBundle(builtDirectory: DirectoryPath, site: SiteAddress | null): Promise<boolean> { | ||
| const portal = new GeneratedPortalContext(builtDirectory); | ||
| const bundle = await portal.bundle(site, envInfo.getCLIVersion(), new Date()); |
There was a problem hiding this comment.
bundle() isn't guarded, so a read error stops generate before the portal is saved. The bundle also repeats a slice of the spec for every operation, which llms-full.txt deliberately avoids, so it grows large.
There was a problem hiding this comment.
Both fixed:
- A read error (
53d0a790) returns an error instead of throwing.generatewarns and still saves the portal. - The spec slice (
a2ce8488): pages are rendered asllms-full.txtrenders them, without each operation's slice of the specification. The e2e test checks that no operation page carries one.
| readonly chatAddress = (): string => `${this.copilot.apiUrl}/v1/chat`; | ||
|
|
||
| /** The body for one question: only the latest message, since the service keeps the thread. */ | ||
| questionBody(messages: readonly unknown[], trigger: string): Record<string, unknown> { |
There was a problem hiding this comment.
AIChatProvider adds location.href and document.title to every message, so the service receives visitors' full URLs. Strip them, or document it.
There was a problem hiding this comment.
The full page URL with every message Stripped, in 5e9293b1.
The chat now builds its questions through toMessage, sending:
- the page: origin and path only, with no query or fragment (for example, an OAuth callback's
?code=is never sent); - the page's title, as before.
A test checks that a callback address loses its code.
…the saved login `--auth-key ""` passed the action's null check, then `authKey || saved` in the service sent the saved APIMatic login to any service address. The action now decides the key in one place: a passed key with content, else the saved login only for APIMatic's own service or one on this machine. The service takes the key it is given. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dds the base path once The chat's source chips and search results render Fumadocs' Link, which the TanStack router serves under its basepath. Prefixing the base path as well linked `/docs/docs/...` under a sub-path, and every source 404'd. The service's paths now go through unchanged, and a test renders the chat's sources under a base path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed again The build ID hashed the bundle's exact bytes, `generatedAt` among them, so it changed on every `generate` and the service re-fetched the pages on every deploy. It now hashes what the service reads: the format version, the site address, the base path and the pages, leaving out when and by which CLI the bundle was generated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ervice on this machine `*.localhost` names got plain HTTP and the saved login, but some systems, Windows' Node among them, resolve them through DNS, so they may not be this machine. The parser and the schema now accept only the exact loopback hosts, and the schema test checks that they agree on the Ask AI service's address. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A slow upload or status check failed without a response, so it was reported as a service that could not be reached, with advice to check the address. The ingest service now has axios tell a timeout apart (`clarifyTimeoutError`) and reports it as one, naming the time it waited. The prompts word a timeout per step: sync again after an upload, check back later while indexing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ve failed indexing its own error kind The indexing poll copied `pollUntilCompleted`'s deadline loop. The loop is now `pollUntilSettled`, which takes a `settle` function saying how a status ends the run; `pollUntilCompleted` settles the generation statuses with it, and the Ask AI service its indexing states. Failed indexing came back as `badRequest`, its message written in the service. It is now `ProcessingFailed`, carrying only the service's reason, and the sync prompt words it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`GeneratedPortalContext.bundle()` threw on a file it could not read, which stopped `generate` before the portal was saved, and crashed `sync`. It now returns the error: `generate` warns and saves the portal without the pages, pointing to sync, and `sync` fails without uploading. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The session opened only with the first question, so on a portal the service turns away, visitors saw "Ask AI", asked, and got an error before the button hid; Ctrl+/ still opened the panel after that. The session now opens as the panel opens, and once the service turns the portal away the panel stays shut. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…I no longer parses llms.txt The CLI read the page list from `llms.txt` and rebuilt each page's Markdown file from its link, which dropped pages with non-ASCII names, let a user's own `static/llms.txt` decide what Ask AI saw, missed pages the navigation leaves out, and had `sync` bundle under today's base path. A portal with Ask AI now builds `/api/copilot-pages.json` from the template's own page source, rendered as `llms-full.txt` renders it, without each operation's slice of the specification. `generate` bundles those pages and removes the file, so it is never deployed; `sync` uploads the bundle `generate` wrote, as it is. `llms-index.ts` goes away. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…with --zip A portal generated with `--zip` holds only `portal.zip`, so `sync` found no bundle and asked the user to generate the portal they had just generated. It now reads `__copilot/bundle.json` from inside the archive when the directory has none, through a new `ZipService.readEntry`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… no build paths The test said "and no more" but only checked that the key and the service reached the browser. It now compares the chat settings the browser imports as a whole, and checks that no published file of the Ask AI portal names the build machine's directories, through the helper the default portal's checks already used, moved to module level. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…chine, so sync never sends the login elsewhere `sync` sends the APIMatic login to the Ask AI service, but the `portal` block accepted any HTTPS address, and asked users to pass their account key with --auth-key for a service APIMatic does not run. The block, and the schema with it, now accept only `https://` on `apimatic.io` or one of its subdomains, or a service on this machine. The saved-login check this made unreachable goes, so `sync` uses the passed key or else the saved login, and the `auth login` hint on a refusal always applies. The schema now also agrees with the parser on an address in capitals and on one with credentials before its host. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s query or fragment Fumadocs' chat put `location.href` in every question, so the service received each visitor's whole address, an OAuth callback's code among it. The chat now builds its questions through `toMessage`, naming the page by its origin and path only, with its title as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and refuse an address with a path The service address was parsed, kept as a string and handled as one through `sync`, the ingest service and the prompts, and a path such as `/v1` was dropped without a word. `ServiceAddress` now holds the rules in one place: APIMatic's own service over HTTPS, or one on this machine, as its origin alone, refusing a path, a query, a fragment or credentials. The settings keep it, `sync` passes it on, and it becomes a string only where it leaves the CLI. The schema refuses what the parser refuses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e and sync --help give the same advice `generate` said Ask AI picks up the pages by itself, while `sync --help` said to run it after every deploy, and a portal without `site.url` was told the same, though the service cannot find it. `generate` now names the address the service fetches the pages from, or, without one, says to deploy and run `sync`; `sync --help` says when it is needed: for a portal without `site.url`, or one the service cannot reach. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r it Every portal paid for the chat: `app.css` imported its preset, whose `@source` wrote the chat's classes into each stylesheet, and the layout's `import()` of the chat made the build emit its chunk into every output. The CLI now decides both from the `portal` block. `theme.css` imports the chat's preset only for a portal with Ask AI, and the CLI writes `src/lib/copilot-entry.ts`, which holds the chat's `import()` only then; without it, the module names the chat as a type alone, and the build has nothing to make a chunk of. `portal serve` rewrites both as the block changes. Built outside a repository, a portal without the chat drops from the chat's 105,222 bytes of CSS to 97,272, ships no chat chunk, and loads 5.4 KB less JavaScript (gzipped) up front. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The chat's lazy component wrapped the whole layout, so a page with the chat stayed inert until the chat's chunk had arrived. The layout now renders on its own, keeping the chat's open state and a place for its panel; the chat loads beside it, fills that place through a portal, and keeps its open state and the layout's as one, so its button, Escape, Ctrl+/ and the panel's close button behave as before. The generated `copilot-entry.ts` asks for the chunk as the page's scripts run. On Slow 4G over HTTP/2, a page with the chat became interactive in 5.17 s rather than 6.10 s, against 4.33 s for one without it. The template is now also type-checked with the chat's `copilot-entry.ts`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…AI service checks them The Ask AI service refreshes a deployed portal by fetching build.json, then bundle.json, and refuses the bundle unless its bytes hash to build.json's buildId (copilot-service, src/ingest/portal-fetch.ts). 62a5aac hashed the pages alone, so that check failed on every deploy and a portal never refreshed by itself. The build ID names the bundle's exact bytes again. Regenerating the same pages still changes the ID, since generatedAt does; the service then finds the pages unchanged and indexes nothing again, so a redeploy costs one fetch of the bundle. The tests now pin the service's contract against the files as written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # package.json # pnpm-lock.yaml # portal-template/vite.config.ts
…s classes either #440 built the project where Tailwind no longer picks up classes nothing uses, so the default portal's stylesheet can be held to having none of the chat's, as its code already was. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…vice address The parser rewrote some addresses the schema refused (127.1, [0:0::1], an encoded dot, an empty port), and the schema took some the parser refused (a port past 65535, a colon inside a host name). The parser now takes an address only as written, and refuses a host with an empty label; the schema allows sub-domains of letters, digits, hyphens and underscores, and range-checks the port. A test runs 24 addresses through both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
takePages deleted api/copilot-pages.json only after reading it, so a read that failed left the file in the portal, to be deployed. It is now deleted whether the read works or not. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tal without pages Both were reported as "The portal has no pages for Ask AI to answer from", which sends the author through their docs when the build is at fault. A build that wrote no page file now says to generate again, and whom to contact if the pages are still missing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… that read a dependency's build The comments the review fixes added ran to several lines; each now says why in one, as the repository asks. The test that matched the chat package's minified code would break on a harmless release of it; the test that renders the chat's links under a base path stays, in a file named for what it covers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…template literal Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed template literal Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…brary does Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|



Summary
Generated portals can now offer an Ask AI chat that answers questions from the portal's own pages. It is opt-in: a portal turns it on with a new
portal.ai.copilotblock insrc/apimatic.json. A portal without that block builds exactly as before, and an e2e test checks that it doesn't download any chat code.This PR adds three things:
@fumadocs/ai-chatpanel, sits behind an "Ask AI" button and is answered by an external Ask AI service. The portal itself stays static.apimatic portal generatenow writes the pages' Markdown into<portal>/__copilot/so the Ask AI service can fetch them from the deployed site.apimatic portal copilot synccommand. It pushes a generated portal's pages straight to the Ask AI service. Use it when the service can't reach the deployed portal, or when you want to update the chat right away.Configuration
apimatic.schema.jsondescribes the same block.apiUrlis required for now because there is no hosted service yet. Once there is one, it should become a default that this setting overrides (there is a comment about this incopilot-config.ts).How it works
CopilotBundle, format version 1) holdsgenerator,generatedAt,siteUrl,basePathandpages[] { path, title, description?, markdown }. The page list comes from the portal'sllms.txt, each page's text from its Markdown copy, and pages under__downloads/are skipped.build.jsonholds the SHA-256 of the bundle, so the service downloads it again only when a deploy changed it.portal copilot syncafter deploying. It does not fail the build.portalsettings → stops early if there is no copilot block, no generated portal or no pages → uploads → reportsunchangedstraight away. Otherwise it waits for indexing, unless--no-waitis passed. Indexing is polled every 2 s for up to 10 minutes. A version replaced by a newer sync (superseded) counts as success, with a warning. Failures are reported through the telemetry eventPortalCopilotSyncFailedEvent.What reviewers should look at
🔐 Security decisions
receivesSavedLogin()incopilot-config.tsallowsapimatic.io,*.apimatic.ioand loopback addresses. For any otherapiUrl, sync refuses to run unless the user passes--auth-keyexplicitly. That stops a third party'sapimatic.jsonfrom collecting a user's credentials.http://is accepted only for loopback, so the keys are never sent unencrypted (the config parser and the JSON schema both check this).credentials: 'omit', so portal cookies are never sent.copilot_disabled,invalid_key,origin_not_allowed), the Ask AI button hides itself.📦 Dependencies
@fumadocs/ai-chat0.1.0(pinned)@ai-sdk/react4.0.131useChatai7.0.128DefaultChatTransportfumadocs-core,fumadocs-ui16.15.17→16.16.2@fumadocs/ai-chatneedsfumadocs-ui >= 16.16All three new packages are added to
LINKED_DEPENDENCIESinportal-project-service.ts, so the portal build can resolve them. Most of thepnpm-lock.yamlchange comes from these packages.⚡ Bundle size
The chat is loaded with
React.lazy, so it is its own chunk (portal-template/src/lib/layout.tsx). A portal withoutcopilotnever downloads it. A portal with it still prerenders the button, because the prerender waits for the chunk. Both cases have e2e tests.🔌 Contract with the Ask AI service (not in this repo)
The paths below are built into every published portal, so the service has to keep serving them. A breaking change needs a
/v2path.POST /v1/ingestAuthorization: X-Auth-Key <key>andX-Copilot-Key: <public key>; answers{ versionId, status: queued|unchanged, pageCount }GET /v1/ingest/{versionId}{ status: queued|building|active|superseded|failed, pageCount, changedPages, removedPages, chunkCount, error? }POST /v1/sessions{ key }→{ token, expiresAt }POST /v1/chatBearertoken; body{ threadId, trigger, message }, carrying only the latest message (the service keeps the thread); streamsdata-thread,data-sourcesandtool-searchpartsErrors come back as
{ code, message }. The CLI shows themessagefor 400, 401, 403, 404 and 413.Other things to know
portal:copilot, and the README section is regenerated.eslint.config.jsadds thefetch/Response/Headersglobals to the test config, because the portal template's browser code is tested against Node's ownfetch.copilot-links.tsadds the portal's base path to those links, so they work when the portal is served under a sub-path.File map
src/commands/portal/copilot/sync.tssrc/actions/portal/copilot/sync.ts,src/actions/portal/generate.ts(writes the bundle)src/prompts/portal/copilot/sync.ts,src/prompts/portal/generate.tssrc/infrastructure/services/copilot-ingest-service.ts,src/infrastructure/portal-project-service.tssrc/types/portal/config/copilot-config.ts,ai-config.ts,portal-config.ts;src/types/portal/copilot/{copilot-bundle,generated-portal-context,ingest,llms-index}.ts;src/types/events/portal-copilot-sync-failed.tscomponents/copilot.tsx,lib/copilot-client.ts,lib/copilot-links.ts,lib/layout.tsx,lib/portal-types.ts,styles/app.cssapimatic.schema.json,package.json,pnpm-lock.yaml,eslint.config.js,README.mdTesting
Run locally on this branch, with the latest
devmerged in:pnpm buildpnpm lintpnpm test: 1911 passing, 72 pending (the e2e suites, which are skipped unlessAPIMATIC_E2E=1is set)APIMATIC_E2E=1test/e2e/portal-build.test.ts: 70 passing, which builds real portals with and without the chatNew tests:
test/actions/portal/copilot/sync.test.ts): a full sync that waits for indexing;unchanged;--no-wait;superseded; the service refusing the upload; the service unreachable; indexing failing; the saved login withheld from untrusted services and sent to APIMatic's and loopback ones; failing early with no copilot block, noapimatic.json, no generated portal or no Markdown pages.copilot-ingest-service.test.ts, nock): the upload's headers and gzipped body; stored key vs. flag; no key → asked to sign in, no request sent; 400/413 messages; network errors; polling toactive/superseded/failed; timeout; a status body it can't read.llms.txt(base path,__downloads,..paths), building the bundle from a generated portal, and validating thecopilotblock inportal-config.CopilotClient, and base-path links incopilot-links.portal-build.test.ts, newcopilotfixture): the chat button is prerendered; the chat is in its own chunk; a real build lists its pages with their Markdown; the browser gets only the public key and the service address; a portal without the chat loads no chat code.generate.test.ts: the bundle and its build ID are written with the portal; a zipped portal includes them; a portal with no pages still saves, and the user is pointed tosync.Manual check
copilotblock to a project'ssrc/apimatic.json, pointing at a local Ask AI service (http://localhost:8787).apimatic portal generateand check thatportal/__copilot/bundle.jsonandbuild.jsonexist.apimatic portal serve, open "Ask AI", ask a question and check that the source links open the right pages.apimatic portal copilot sync, then again (it should report "already up to date"), then with--no-wait.apiUrlat a non-APIMatichttps://host without--auth-key. Sync should refuse to send the saved login.Commits
de67589feat(portal): add an Ask AI copilot chat and aportal copilot synccommand7ca4d20test(portal): cover llms.txt parsing and make the copilot tests pass lint7d039c5fix(portal): keep the saved APIMatic login from Ask AI services it does not trust08dffb5perf(portal): load the Ask AI chat only in portals that offer it48c4d17refactor(portal): tidy the Ask AI copilot and test how its chat links pages1e77599feat(portal): publish the Ask AI bundle with the generated portal5c73989mergesdev(brings in feat(portal): build a portal from the spec folders APIMATIC-META.json merges #435, already reviewed. It is not part of this diff.)9f1927atest(portal): restores the Ask AI e2e suite that the merge had folded into the merged-spec-folders block🤖 Generated with Claude Code