Skip to content

feat(portal): add an Ask AI copilot chat and a portal copilot sync command - #438

Open
waseem196 wants to merge 44 commits into
devfrom
feat/portal-ai-copilot
Open

waseem196 wants to merge 44 commits into
devfrom
feat/portal-ai-copilot

Conversation

@waseem196

Copy link
Copy Markdown

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.copilot block in src/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:

  1. The chat in the portal template. It uses Fumadocs' @fumadocs/ai-chat panel, sits behind an "Ask AI" button and is answered by an external Ask AI service. The portal itself stays static.
  2. A copy of the pages published with the portal. apimatic portal generate now writes the pages' Markdown into <portal>/__copilot/ so the Ask AI service can fetch them from the deployed site.
  3. A new apimatic portal copilot sync command. 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

// src/apimatic.json
{
  "portal": {
    "ai": {
      "copilot": {
        "key": "pk_…",                        // public key, bound to this portal and its allowed origins
        "apiUrl": "https://ask.example.com",  // the Ask AI service; http:// is accepted only for localhost
        "suggestions": ["How do I authenticate?"] // optional, up to 3, each at most 120 characters
      }
    }
  }
}

apimatic.schema.json describes the same block. apiUrl is 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 in copilot-config.ts).

How it works

portal generate ──► build ──► llms.txt + <page>.md files
                                  │
                                  ▼
                       GeneratedPortalContext.bundle()
                                  │
                ┌─────────────────┴──────────────────┐
                ▼                                    ▼
  written to <portal>/__copilot/          portal copilot sync
  bundle.json + build.json               POST {apiUrl}/v1/ingest (gzip)
  (deployed, so the service              then polls GET /v1/ingest/{versionId}
   fetches it)                           until the version is active

Browser:  "Ask AI" button ──► POST /v1/sessions {key} → short-lived token
                          ──► POST /v1/chat (Bearer token, streamed reply)
  • The bundle (CopilotBundle, format version 1) holds generator, generatedAt, siteUrl, basePath and pages[] { path, title, description?, markdown }. The page list comes from the portal's llms.txt, each page's text from its Markdown copy, and pages under __downloads/ are skipped. build.json holds the SHA-256 of the bundle, so the service downloads it again only when a deploy changed it.
  • generate: the bundle is written before the portal is saved, so a zipped portal includes it too. If the bundle can't be written, generate only warns and tells the user to run portal copilot sync after deploying. It does not fail the build.
  • sync: reads the portal settings → stops early if there is no copilot block, no generated portal or no pages → uploads → reports unchanged straight away. Otherwise it waits for indexing, unless --no-wait is 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 event PortalCopilotSyncFailedEvent.

What reviewers should look at

🔐 Security decisions

  • The saved APIMatic login goes only to services we trust. receivesSavedLogin() in copilot-config.ts allows apimatic.io, *.apimatic.io and loopback addresses. For any other apiUrl, sync refuses to run unless the user passes --auth-key explicitly. That stops a third party's apimatic.json from collecting a user's credentials.
  • Plain http:// is accepted only for loopback, so the keys are never sent unencrypted (the config parser and the JSON schema both check this).
  • The browser only gets the public key and the service address. The e2e test checks this. The chat swaps the key for a short-lived session token and renews it 30 s before it expires or once after a 401. Every request uses credentials: 'omit', so portal cookies are never sent.
  • If the service turns the portal away (copilot_disabled, invalid_key, origin_not_allowed), the Ask AI button hides itself.

📦 Dependencies

Package Change Why
@fumadocs/ai-chat new, 0.1.0 (pinned) The chat panel. It is 0.x: check the chat by hand on every upgrade.
@ai-sdk/react new, 4.0.131 useChat
ai new, 7.0.128 DefaultChatTransport
fumadocs-core, fumadocs-ui 16.15.17 → 16.16.2 @fumadocs/ai-chat needs fumadocs-ui >= 16.16

All three new packages are added to LINKED_DEPENDENCIES in portal-project-service.ts, so the portal build can resolve them. Most of the pnpm-lock.yaml change 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 without copilot never 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 /v2 path.

Caller Endpoint Notes
CLI POST /v1/ingest gzipped bundle; headers Authorization: X-Auth-Key <key> and X-Copilot-Key: <public key>; answers { versionId, status: queued|unchanged, pageCount }
CLI GET /v1/ingest/{versionId} { status: queued|building|active|superseded|failed, pageCount, changedPages, removedPages, chunkCount, error? }
Browser POST /v1/sessions { key } → { token, expiresAt }
Browser POST /v1/chat Bearer token; body { threadId, trigger, message }, carrying only the latest message (the service keeps the thread); streams data-thread, data-sources and tool-search parts

Errors come back as { code, message }. The CLI shows the message for 400, 401, 403, 404 and 413.

Other things to know

  • New oclif topic portal:copilot, and the README section is regenerated.
  • eslint.config.js adds the fetch/Response/Headers globals to the test config, because the portal template's browser code is tested against Node's own fetch.
  • The service links pages by their path inside the portal. copilot-links.ts adds the portal's base path to those links, so they work when the portal is served under a sub-path.
  • No breaking changes. Portals without the new block build as before.

File map

Layer Files
Command src/commands/portal/copilot/sync.ts
Action src/actions/portal/copilot/sync.ts, src/actions/portal/generate.ts (writes the bundle)
Prompts src/prompts/portal/copilot/sync.ts, src/prompts/portal/generate.ts
Infrastructure src/infrastructure/services/copilot-ingest-service.ts, src/infrastructure/portal-project-service.ts
Types src/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.ts
Portal template components/copilot.tsx, lib/copilot-client.ts, lib/copilot-links.ts, lib/layout.tsx, lib/portal-types.ts, styles/app.css
Config / docs apimatic.schema.json, package.json, pnpm-lock.yaml, eslint.config.js, README.md

Testing

Run locally on this branch, with the latest dev merged in:

  • ✅ pnpm build
  • ✅ pnpm lint
  • ✅ pnpm test: 1911 passing, 72 pending (the e2e suites, which are skipped unless APIMATIC_E2E=1 is set)
  • ✅ APIMATIC_E2E=1 test/e2e/portal-build.test.ts: 70 passing, which builds real portals with and without the chat

New tests:

  • Action (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, no apimatic.json, no generated portal or no Markdown pages.
  • Service (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 to active/superseded/failed; timeout; a status body it can't read.
  • Types: parsing llms.txt (base path, __downloads, .. paths), building the bundle from a generated portal, and validating the copilot block in portal-config.
  • Portal template: the session, token renewal, thread tracking and turn-away handling in CopilotClient, and base-path links in copilot-links.
  • e2e (portal-build.test.ts, new copilot fixture): 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 to sync.

Manual check

  1. Add the copilot block to a project's src/apimatic.json, pointing at a local Ask AI service (http://localhost:8787).
  2. Run apimatic portal generate and check that portal/__copilot/bundle.json and build.json exist.
  3. Run apimatic portal serve, open "Ask AI", ask a question and check that the source links open the right pages.
  4. Run apimatic portal copilot sync, then again (it should report "already up to date"), then with --no-wait.
  5. Point apiUrl at a non-APIMatic https:// host without --auth-key. Sync should refuse to send the saved login.

Commits

  • de67589 feat(portal): add an Ask AI copilot chat and a portal copilot sync command
  • 7ca4d20 test(portal): cover llms.txt parsing and make the copilot tests pass lint
  • 7d039c5 fix(portal): keep the saved APIMatic login from Ask AI services it does not trust
  • 08dffb5 perf(portal): load the Ask AI chat only in portals that offer it
  • 48c4d17 refactor(portal): tidy the Ask AI copilot and test how its chat links pages
  • 1e77599 feat(portal): publish the Ask AI bundle with the generated portal
  • 5c73989 merges dev (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.)
  • 9f1927a test(portal): restores the Ask AI e2e suite that the merge had folded into the merged-spec-folders block

🤖 Generated with Claude Code

waseem196 and others added 8 commits October 15, 2026 11:08
…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>
…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>
waseem196 and others added 8 commits October 19, 2026 12:45
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>

@saeedjamshaid saeedjamshaid left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. An empty --auth-key sends the saved APIMatic login to any apiUrl.
  2. Under a sub-path (site.url = https://x.com/docs), the chat's source links go to /docs/docs/… and 404.
  3. Pages with non-ASCII names are silently left out of the bundle.
  4. A user's own static/llms.txt decides what Ask AI sees. With full URLs, it sees nothing.
  5. Portals without the chat still change. Measured on the default fixture against dev:
    • CSS: +11.7 KB raw, of which the chat's @source is 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.
  6. 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 generate before the portal is saved.
  • sync can't read a zipped portal.
  • Timeouts are reported as "could not be reached".
  • generate and sync --help give different advice on when to sync.
  • *.localhost is trusted as loopback.
  • Every chat message sends the visitor's full page URL.

Design

  • The page list is parsed from llms.txt instead 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 /path line and the ```yaml block, code samples included), so the layout llms.server.ts writes 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/types/portal/copilot/llms-index.ts Outdated
description: string | null;
}

const LINK = /^\s*[-*]\s+\[(.*)\]\(([^)\s]+)\)(?::(.*)|\s*)$/;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.txt replaces the file;
  • sync uses 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • sync and today's base path: sync uploads the bundle generate wrote, under the base path it was built with.
  • Escaping: links are no longer parsed.
  • Hidden pages: included.

);
}

public copilotPagesNotFound() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread portal-template/src/styles/app.css Outdated
@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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread portal-template/src/lib/layout.tsx Outdated
log.error(message);
}

public portalNotGenerated(portalDirectory: DirectoryPath) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeouts land here as network errors, so a slow upload or status check reports "could not be reached. Check the address".

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 as ETIMEDOUT.
  • 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/actions/portal/generate.ts Outdated
/** 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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed:

  • A read error (53d0a790) returns an error instead of throwing. generate warns and still saves the portal.
  • The spec slice (a2ce8488): pages are rendered as llms-full.txt renders 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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AIChatProvider adds location.href and document.title to every message, so the service receives visitors' full URLs. Strip them, or document it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

waseem196 and others added 11 commits October 19, 2026 16:15
…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>
waseem196 and others added 14 commits October 20, 2026 09:33
…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>
waseem196 and others added 3 commits October 21, 2026 18:23
…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>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants