Mert Özen Further With Every Line
Artificial Intelligence AI Interfaces with Angular — #09

AI in the Browser and Angular 22's Experimental AI Tooling: Built-in AI APIs and WebMCP

Mert Özen Nov 14, 2026 15 min 2 views
AI in the Browser and Angular 22's Experimental AI Tooling: Built-in AI APIs and WebMCP

Chrome's built-in AI APIs and Angular 22's experimental WebMCP functions should only be used with feature detection, a time limit and a server path that always works. In this post we show Summarizer returning downloadable, unavailable or nothing across four Chromium 158 setups, produce a review summary on the device first and on the server otherwise, hit NG0203 from inject() after await in a WebMCP tool, and see Angular stay silent in unsupported browsers.

Version note: The code in this post was built and run with Angular 22.2 (@angular/core 22.2.6, Angular CLI 22.2.7, TypeScript 6.0, Node.js 24) and .NET 10; the backend uses Microsoft.Extensions.AI 11.0.0 and OllamaSharp 5.5.2, browser runs used Playwright 1.65 with Chromium 158 (headless shell and the new headless mode), and the server-side summary uses Ollama 0.34.4 (qwen3:4b). Chrome's on-device model (Gemini Nano) was not downloaded; the on-device path was tried with a fake Summarizer. npx skills add was not run.

Guess First

Chrome's Summarizer API has been stable for web pages since Chrome 138. You want to use it on the book page of Bookstore (Bookstore, the online bookstore used as the shared example across this site's series) to summarize reviews: on the user's device, without paying for a server and a model. What does Summarizer.availability() return in Playwright's Chromium 158: available, downloadable or unavailable? Second question: you exposed the application's cart to an agent in the browser with Angular 22's experimental WebMCP API, but the browser does not support WebMCP. Does the app throw, log a warning, or does nothing happen at all?

This last post of the series is the one that will age fastest: browser-side models and agents using web pages as tools are not settled yet. So we measure first, then build every feature so that it also works in browsers that do not support it (progressive enhancement).

Models Running in the Browser: the Built-in AI APIs

Chrome's built-in AI APIs call a small model downloaded by the browser (Gemini Nano for most of them) from JavaScript. The status table in Chrome's documentation looked like this on the day of writing:

APIWeb pagesNote
Translator, Language DetectorStable since Chrome 138The Gemini Nano hardware requirements below are not listed for these; they are not on mobile either
SummarizerStable since Chrome 138Gemini Nano
Prompt API (LanguageModel)Stable since Chrome 148 according to the tableThe text on the same page still talks only about extensions; sampling parameters are in a separate origin trial
Writer, Rewriter, ProofreaderDeveloper trialTried behind a flag; Proofreader also has an origin trial

The documentation also lists hardware requirements for the APIs that use Gemini Nano: Windows 10/11, macOS 13 or later, Linux or a Chromebook Plus; at least 22 GB of free space on the volume with the Chrome profile; a GPU with more than 4 GB of VRAM, or a CPU with 16 GB of RAM and at least 4 cores; an unmetered connection for the first download. These APIs are not available on Android and iOS. So for all of Bookstore's mobile users and part of its desktop users, the answer is "unavailable" from the start.

Each API's availability() method returns one of four values: unavailable, downloadable, downloading, available. If the model has not been downloaded yet, calling create() requires user activation: a page cannot start a multi-gigabyte download on its own.

Measure First: One Page, Four Browser Setups

I opened the same Bookstore page in Playwright's two Chromium modes and with two different flags, and read each API's availability() value and the WebMCP object. On the first attempt the script never finished; the Translator's availability() call never returned in the headless shell. With a 5-second limit on each call:

$ node run09-probe.mjs
headless shell, default                          {"ua":"Chrome/158.0.8127.18","Summarizer":"downloadable","Translator":"no answer in 5 s","LanguageDetector":"unavailable","LanguageModel":"downloadable","Writer":"missing","navigatorModelContext":"undefined","documentModelContext":"undefined"}
chromium (new headless), default                 {"ua":"Chrome/158.0.0.0","Summarizer":"unavailable","Translator":"downloadable","LanguageDetector":"unavailable","LanguageModel":"unavailable","Writer":"missing","navigatorModelContext":"undefined","documentModelContext":"undefined"}
chromium, --enable-features=WebMCP               {"ua":"Chrome/158.0.0.0","Summarizer":"unavailable","Translator":"downloadable","LanguageDetector":"unavailable","LanguageModel":"unavailable","Writer":"missing","navigatorModelContext":"undefined","documentModelContext":"object"}
chromium, experimental web platform features     {"ua":"Chrome/158.0.0.0","Summarizer":"unavailable","Translator":"downloadable","LanguageDetector":"unavailable","LanguageModel":"unavailable","Writer":"unavailable","navigatorModelContext":"undefined","documentModelContext":"object"}

Same version, same machine, four different results. The new headless mode also logged this warning: "On-device AI is disabled in Chrome settings. To use Built-In AI APIs, enable "On-device AI" in chrome://settings/ai." So the presence of the API is not enough; the user or a company policy can turn on-device AI off in the settings. Three rules followed from this and became the skeleton of the code: check that the API exists, wait for availability() with a time limit, and always have a server path that works.

Review Summary in Bookstore: Device First, Then Server

The feature check and the on-device summary live in one file. Since Summarizer is not in TypeScript's DOM types, I declared only as much of it as we use:

// Feature check: if the API is missing or availability() does not return within 3 seconds, there is no on-device summary
export async function onDeviceAvailability(): Promise<Availability> {
  const summarizer = api();
  if (!summarizer) {
    return 'unavailable';
  }
  const timeout = new Promise<Availability>((resolve) => setTimeout(() => resolve('unavailable'), 3000));
  return Promise.race([summarizer.availability(OPTIONS), timeout]).catch(() => 'unavailable' as const);
}

// Downloads the model if needed (requires user activation), reports progress as 0..1 and summarizes
export async function summarizeOnDevice(text: string, onProgress: (loaded: number) => void, signal?: AbortSignal): Promise<string> {
  const summarizer = api();
  if (!summarizer) {
    throw new Error('Summarizer API is not available.');
  }
  const session = await summarizer.create({
    ...OPTIONS,
    monitor: (m) => m.addEventListener('downloadprogress', (e) => onProgress((e as ProgressEvent).loaded)),
  });
  try {
    return await session.summarize(text, { signal });
  } finally {
    session.destroy();
  }
}

OPTIONS sets the summary's type (tldr), length (short), format (plain-text) and context ("Customer reviews of a book in an online bookstore."). The component connects these two functions with Angular's resource family: reviews come through httpResource, the device status and the summary through resource. Both became public API in Angular 22.0; the resource stream from post 2 is part of the same family.

protected readonly reviews = httpResource<Review[]>(() => `/reviews/${this.isbn()}`);
protected readonly availability = resource({ loader: () => onDeviceAvailability() });
protected readonly progress = signal<number | null>(null);
private readonly preferDevice = signal(false);

protected readonly summary = resource({
  params: () => {
    const text = this.reviewText();
    const availability = this.availability.value();
    return text === undefined || availability === undefined
      ? undefined
      : { isbn: this.isbn(), text, onDevice: availability === 'available' || this.preferDevice() };
  },
  loader: async ({ params, abortSignal }): Promise<Summary> => {
    if (params.onDevice) {
      const text = await summarizeOnDevice(params.text, (p) => this.progress.set(p), abortSignal);
      this.progress.set(null);
      return { summary: text, source: 'device' };
    }
    const response = await fetch(`/reviews/${params.isbn}/summary`, { signal: abortSignal });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return response.json();
  },
});

As long as params returns undefined, the loader does not run; once the reviews and the device status arrive, a single decision is made. The server side produces the summary with the same IChatClient we have used since the start of the series and caches the result for 10 minutes; the same book's reviews are not summarized again for every visitor:

// Server summary for browsers that cannot summarize on device; cached because reviews rarely change
app.MapGet("/reviews/{isbn}/summary", async Task<IResult> (string isbn, IChatClient chat, IMemoryCache cache,
    ILogger<ReviewSummary> logger, CancellationToken ct) =>
{
    if (!ByIsbn.TryGetValue(isbn, out IReadOnlyList<Review>? reviews))
    {
        return TypedResults.NotFound();
    }
    ReviewSummary? summary = await cache.GetOrCreateAsync($"review-summary:{isbn}", async entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        string text = string.Join('\n', reviews.Select(r => $"- ({r.Rating}/5) {r.Text}"));
        ChatResponse response = await chat.GetResponseAsync(
            [
                new(ChatRole.System, "Summarize customer reviews of a book in two short sentences. " +
                    "Mention what readers liked and what they criticised. Do not invent details."),
                new(ChatRole.User, text),
            ], cancellationToken: ct);
        logger.LogInformation("Review summary for {Isbn}: {Tokens} output tokens", isbn, response.Usage?.OutputTokenCount);
        return new ReviewSummary(response.Text.Trim(), "server");
    });
    return TypedResults.Ok(summary);
}).RequireRateLimiting("chat");
$ time curl -s localhost:19640/reviews/9780441172719/summary
{"summary":"Readers praised the book's incredible world building, Paul's story feeling modern, and its great ideas.
They criticized the slow start with unfamiliar terms in the first hundred pages and considered the appendix and
glossary almost required reading.","source":"server"}
curl -s localhost:19640/reviews/9780441172719/summary  0.00s user 0.00s system 0% cpu 9.783 total

$ time curl -s localhost:19640/reviews/9780441172719/summary
curl -s localhost:19640/reviews/9780441172719/summary  0.00s user 0.00s system 64% cpu 0.006 total

# server log
Review summary for 9780441172719: 389 output tokens

The first request took 9.8 seconds and 389 output tokens, the second came from the cache in 6 milliseconds. I opened the Dune page in the two Chromium modes:

$ node run09.mjs shell
   163 ms Summarizing reviews…
   267 ms Readers praised the book's incredible world building, ... | Summarized by the Bookstore server.
          AI-generated, may contain mistakes. | Summarize on this device (one-time model download)

$ node run09.mjs chromium
   160 ms Summarizing reviews…
[console.warning] On-device AI is disabled in Chrome settings. To use Built-In AI APIs, enable "On-device AI" in chrome://settings/ai.
   263 ms Readers praised the book's incredible world building, ... | Summarized by the Bookstore server.
          AI-generated, may contain mistakes.

In the headless shell the model is downloadable, so the user sees the server summary with a button next to it; in the new headless mode the on-device path is off and there is no button. In both cases the user sees the summary in under 300 milliseconds. I always show the source ("Summarized by the Bookstore server.") and the "AI-generated" label from post 7: a summary produced on the device and one produced on the server may differ in quality, and the user should know what they are reading.

Model Download and User Activation

The button sets the preferDevice signal to true; the summary resource runs again and create() starts the download with the user activation the click provides. I did not download the real model for this post. To try the flow, Playwright injected a fake Summarizer into the page whose availability() returns downloadable and whose create() sends three downloadprogress events:

# FAKE Summarizer: no model was downloaded
$ node run09.mjs fake-download
    212 ms Readers praised ... | Summarized by the Bookstore server. ... | Summarize on this device (one-time model download)
    340 ms Summarizing 4 reviews… [progress 0]
    569 ms Summarizing 4 reviews… [progress 0.5]
    882 ms Summarizing 4 reviews… [progress 1]
   1193 ms FAKE on-device summary of 4 reviews. | Summarized on your device. AI-generated, may contain mistakes.

This output shows one more thing: when the button is clicked the server summary disappears and "Summarizing…" stays until the download finishes. When a resource's params change, its value resets. Since a real download can take minutes, that is a bad experience; the server summary should stay on screen until the download finishes (see A Small Experiment).

The value of on-device summaries is cost and latency: the model does not run on the server, so requests and the token bill go down. But the summary quality depends on the device's model, and only some users have it. That is why the server path should stay the main path and the on-device path an enhancement.

WebMCP: Exposing the App to the Agent in the Browser

WebMCP is a proposed standard for a web page to expose its own functions to an agent in the browser as tools described with JSON Schema; it is a W3C community group draft. According to Chrome's documentation it is available as an origin trial from Chrome 149, and for local development behind the chrome://flags/#enable-webmcp-testing flag; the experiment approval on blink-dev covered M149 to M156. In the measurement above the object does not exist by default in Chromium 158, appears as document.modelContext with --enable-features=WebMCP, and navigator.modelContext does not exist.

Angular 22's @angular/core has two experimental functions: declareExperimentalWebMcpTool, which declares a tool inside a component and ties it to the component's lifetime, and provideExperimentalWebMcpTools, which provides a list of tools in application or route providers. Ninja Squad's 22.0 article spells the second one in the singular (provideExperimentalWebMcpTool); in the 22.2.6 type definitions it is plural. With an experimental API, only the package you installed tells you what is right. Bookstore's tools:

// Tools exposed to an agent in the browser; execute runs in Angular's injection context
export const BOOKSTORE_WEBMCP_TOOLS: WebMcpToolDescriptor[] = [
  {
    name: 'search_catalog',
    description: 'Searches the Bookstore catalog by title, author or genre and returns matching books with prices in TRY.',
    inputSchema: {
      type: 'object',
      properties: { query: { type: 'string', description: 'Title, author or genre, for example "crime".' } },
      required: ['query'],
    },
    annotations: { readOnlyHint: true },
    execute: async (args) => {
      const query = String((args as { query?: unknown }).query ?? '');
      const books = await firstValueFrom(inject(HttpClient).get<BookCardData[]>('/catalog/search', { params: { q: query } }));
      return JSON.stringify(books);
    },
  },
  {
    name: 'add_to_cart',
    description: 'Adds one book to the customer\'s cart by ISBN-13. It does not place an order; the customer checks out.',
    inputSchema: {
      type: 'object',
      properties: { isbn: { type: 'string', pattern: '^\\d{13}$' } },
      required: ['isbn'],
    },
    execute: async (args) => {
      // inject() only works before the first await; the injection context is limited to the synchronous part
      const http = inject(HttpClient);
      const cart = inject(Cart);
      const isbn = String((args as { isbn?: unknown }).isbn ?? '');
      // We do not trust the ISBN the agent sends: the book comes from the catalog, the price from the server
      const [book] = await firstValueFrom(http.get<BookCardData[]>('/catalog/search', { params: { isbn } }));
      if (!book) {
        return `No book with ISBN ${isbn}.`;
      }
      cart.add(book);
      return `Added "${book.title}" to the cart. The cart has ${cart.count()} item(s).`;
    },
  },
];
providers: [
  provideBrowserGlobalErrorListeners(),
  provideRouter(routes, withComponentInputBinding()),
  provideHttpClient(withFetch()),
  // Experimental: if the browser does not support WebMCP, Angular registers nothing
  provideExperimentalWebMcpTools(BOOKSTORE_WEBMCP_TOOLS),
]

In the first version of add_to_cart the inject(Cart) line came after the await. Calling the tool like an agent does, with document.modelContext.executeTool:

[console.error] WebMCP tool execution failed: Uncaught Error: NG0203: The `_Cart` token injection failed. `inject()`
function must be called from an injection context such as a constructor, a factory function, a fiel...
{ "error": "Error: page.evaluate: OperationError: Tool execution failed." }
header: Cart: 0

I looked at Angular's source: it calls execute inside runInInjectionContext, but that context only holds for the synchronous part of the function; after the first await it is an ordinary JavaScript call that has lost it. Taking the dependencies at the very start fixed it. After the fix, the tool list and two calls:

$ node run09.mjs webmcp
{
  "tools": [
    "add_to_cart {}",
    "search_catalog {\"consequentialHint\":false,\"debugging\":false,\"readOnlyHint\":true,\"untrustedContentHint\":false}"
  ],
  "search": "[{\"isbn\":\"9780062073488\",\"title\":\"And Then There Were None\",\"author\":\"Agatha Christie\",\"priceTry\":16.9},...",
  "add": "Added \"The Hound of the Baskervilles\" to the cart. The cart has 1 item(s).",
  "bad": "No book with ISBN 9780000000000."
}
header: Cart: 1

$ node run09.mjs no-webmcp
{ "modelContext": "undefined" }
header: Cart: 0

The answer to the second question in Guess First: nothing happens. In Angular's source, declareExperimentalWebMcpTool returns silently if it finds neither document.modelContext nor navigator.modelContext, or if it is called during server-side rendering; not a single line in the console. That is the right behavior for progressive enhancement, but it also hides the answer to "why is my tool not showing up" while debugging. There is a cost too: provideHttpClient and the tool definitions went into the initial bundle, and main grew from 263.8 kB to 287.4 kB; this code is downloaded in browsers without WebMCP too.

Signal Forms and Security Questions

provideExperimentalWebMcpForms() in @angular/forms/signals goes one step further: the experimentalWebMcpTool option passed to form() (name, description and annotations.consequentialHint) turns a Signal Form into a tool. According to the 22.0 announcement the input schema is generated from the form's fields, and when the tool is called the form is filled in and submitted. I did not set this up in Bookstore; I would not want to expose an order or payment form to an agent before the following questions are answered.

A WebMCP tool is a new API endpoint that runs with the user's session. The rules from post 5 apply here too: data coming from the agent is validated (the ISBN pattern, the price from the catalog), and an irreversible action is either not a tool or asks for approval. On the browser side there are the readOnlyHint, consequentialHint and untrustedContentHint hints in the tool definition; according to Chrome's documentation, a confirmation dialog for sensitive actions; and the tools Permissions Policy defaulting to self, so a cross-origin iframe cannot expose tools without allow="tools". On your side you also need to ask: which agent calls the tool (a browser extension can too), what user data leaves through its result, and can a prompt injection in a page the agent reads steer it into calling this tool? The LLM Application Security series covers the threat model.

Angular's AI Developer Tooling

Angular's other AI-related side is not about the application but about the developer's tools. Angular CLI has included an MCP server since 20.1, and an AI assistant in the IDE can connect to it. On 22.2.7 I called tools/list like an MCP client:

$ npx ng mcp --help
ng mcp

Options:
  --help        Shows a help message for this command in the console.  [boolean]
  --root        Allowed root directory paths for filesystem access and workspace discovery. Can be specified multiple times.  [array]
  --read-only   Only register read-only tools.  [boolean] [default: false]
  --local-only  Only register tools that do not require internet access.  [boolean] [default: false]

$ node mcp-list.mjs
server: angular-cli-server 22.2.7
ai_tutor                     readOnly=true openWorld=false
get_best_practices           readOnly=true openWorld=false
search_documentation         readOnly=true openWorld=true
list_projects                readOnly=true openWorld=false
onpush_zoneless_migration    readOnly=true openWorld=false
run_target                   readOnly=false openWorld=false
devserver_start              readOnly=false openWorld=false
devserver_stop               readOnly=false openWorld=false
devserver_wait_for_build     readOnly=true openWorld=false

$ node mcp-list.mjs --read-only --local-only
server: angular-cli-server 22.2.7
ai_tutor                     readOnly=true openWorld=false
get_best_practices           readOnly=true openWorld=false
list_projects                readOnly=true openWorld=false
onpush_zoneless_migration    readOnly=true openWorld=false
devserver_wait_for_build     readOnly=true openWorld=false

With --read-only --local-only the assistant cannot run builds or the dev server and cannot go to the internet; making that the default for a team makes sense. According to Ninja Squad's 22.0 article, code generation guidance now lives in a separate "skill" package (npx skills add https://github.com/angular/skills; angular-new-app and angular-developer), which is why the find_examples and modernize tools were removed from the MCP server in 22. I did not install the skill package for this post.

On the application side, the status of the APIs used in this series is clear in the 22.2.6 type definitions: resource and httpResource are @publicApi 22.0, so stable; debounced() (a helper that delays a signal and returns a Resource with a loading state), which arrived in 22.0, and the three WebMCP functions are @experimental. Experimental ones can change in the next minor release.

Production Notes

Turn on-device AI on by measuring. Collect how many of your users get available, downloadable and unavailable. Of the four browser setups in this post, only one had a downloadable Summarizer model; with real users the ratio depends on your audience.

Put a time limit on availability(). In the measurement one call never returned; without a limit the page would have waited for the summary forever.

Leave the download to the user and tell them how big it is. The browser already requires user activation; the button's text should say clearly that a download will follow. Better not to show this button to users on a metered connection.

Keep WebMCP an experiment for now. Even the API name in the articles does not match the one in the package, and the browser side is between an origin trial and a flag. If you turn it on in production, start with read-only tools, log every call on the server, and show every action the agent takes on the user's behalf somewhere the user will see it.

Common Pitfalls

First, treating 'Summarizer' in self as enough. The API was there but returned unavailable, was disabled in the settings, or never answered.

Second, leaving the server path for later. For all mobile users and part of the desktop users the server is the only path.

Third, calling inject() after an await in a WebMCP tool's execute. The tool failed with NG0203 and the agent only got "Tool execution failed."

Fourth, taking Angular's silence for "it works". If the browser does not support WebMCP or the flag is off there is no warning at all; check that your tools are registered with document.modelContext.getTools().

Fifth, exposing an irreversible action as a tool. add_to_cart can be undone and the user places the order; if you expose a payment or order tool, consequentialHint and user approval are a must.

A Small Experiment

★☆☆ Keep the server summary on screen while downloading: move the on-device summary into a separate resource, or keep the last successful summary with linkedSignal, and swap it when the download finishes.

★★☆ Translate reviews into the user's language with the Translator API: put a time limit on Translator.availability({ sourceLanguage: 'en', targetLanguage: 'tr' }) as well, and leave translation to the server in browsers that do not support it. Detect the review's language first with the Language Detector.

★★★ Rewrite Bookstore's search form with Signal Forms, turn it into a tool with the experimentalWebMcpTool option and add provideExperimentalWebMcpForms(). Look at the generated input schema with getTools(): how does it differ from the search_catalog schema you wrote by hand?

Over nine posts we built the Angular side of the Bookstore assistant: showing the SSE stream with signals, errors and cancellation with a state machine, safe markdown, tool calls, approvals and generative UI, standard protocols, accessibility, tests and finally models in the browser. They all share one point: the model answers differently every time, and the UI has to behave correctly the same way every time. The backend details are in the AI with .NET and AI Agents and MCP series, and the security side in the LLM Application Security series.