Skip to content

Commit e50c31b

Browse files
bloveclaude
andauthored
feat(website): AI search optimization — Search Console harness, structured data, and measurement (#826)
* docs(plan): ai search optimization plan for threadplane.ai Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): search console api service-account auth Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): typed search console api wrappers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): cache search console access tokens Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): search console snapshot puller Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): keep partial inspection results and fail loudly on a bad sitemap Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): search console analysis report Pure analysis helpers (striking distance, zero-impression pages, unindexed, canonical mismatches, weak CTR) plus a markdown report generator over the .gsc snapshots. The report reads inspection-errors.json when present so a partial inspection sweep is stated as partial: failed URLs stay in the sitemap inventory, the failure count is reported, and the index-health counts are labelled lower bounds rather than implying a clean bill of health. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(website): cover analysis boundaries and harden the gsc report Tests: real ordering assertions for findStrikingDistance and findWeakCtr (the previous one-row fixture passed with .sort() deleted), table-driven filter boundaries including the inclusive/exclusive asymmetry between impressions and ctr, and first coverage for findCanonicalMismatches and findWeakCtr. The both-canonicals-required policy is now pinned by test and stated in the doc comment. Report: InspectionFailure moves to api.ts so the pull/report serialization contract is declared once; snapshot reads report a missing or corrupt file by name and point at the pull instead of throwing a raw ENOENT, with readOptional keeping its own existence check so genuine absence stays distinguishable; URL comparison normalizes protocol, host case, www, fragment and query string so a tagged URL is no longer reported as a page with zero impressions; all four bullet lists are capped, not just the failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(plan): correct website test command (no nx test target exists) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(website): fix stale positioning proof-point assertion * feat(website): emit honest lastmod in sitemap Google ignores changefreq/priority and uses lastmod when it is honest, so the sitemap now emits only lastmod, derived per route from its real source: blog frontmatter dates, docs .mdx files, and page.tsx templates (plus the solutions data module for the programmatic /solutions/* pages). Times come from git commit history rather than file mtimes: a fresh CI checkout rewrites every mtime to clone time, which would claim the whole site changed on every deploy. Shallow clones are detected and their grafted boundary commits discarded, and any route we cannot date honestly simply omits lastmod rather than fabricating one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): detect shallow clones through the common git dir The `shallow` marker lives in the common git dir, not the per-worktree gitdir, so `--absolute-git-dir` misses it inside a linked worktree (.git/worktrees/<name> vs .git) and the sitemap silently dropped lastmod for every file-derived route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(website): extract sitemap dates and close date-fabrication paths Moves the sitemap date logic out of site-metadata.ts into sitemap-dates.ts and removes the remaining ways a date could be invented: - Delete the file-mtime fallback. "Not shallow" never implied "committed" — a fresh full clone also rewrites every mtime to checkout time, so a single lookup miss could publish a build-time lastmod. - Run git with core.quotePath=false (octal-escaped non-ASCII paths would never match a lookup, silently falling through) and log.showSignature=false (a user's config could interleave gpg: lines into the file list). - Bound the subprocess with timeout/SIGKILL so a stalled git cannot hang the build; a kill throws and degrades like any other git failure. - Require a well-formed "<epoch> <sha1>" remainder before treating a line as a commit header, and take paths verbatim so leading whitespace survives. Blog routes now take the later of the frontmatter date and their .mdx commit time, since lastmod means last modified rather than published. Extracts parseGitLog as a pure function and unit-tests the degradation the design rests on — most importantly that a grafted shallow-boundary commit yields no entry rather than a clone-time one — plus a test pinning the absence of changeFrequency/priority. Integration tests now tolerate a history-less checkout instead of asserting completeness unconditionally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): article metadata + canonical brand spelling Emit article:published_time / modified_time / author / tag on blog posts, with modified_time derived from real git commit times (sitemap-dates) rather than defaulting to the publish date. createPageMetadata gains an optional per-page social image for the Task 9 OG-image routes. Unify the brand on "Threadplane" (was "ThreadPlane" in blog titles and prose) and the docs title separator on an em dash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(website): single-source the blog modified time Drop the redundant `lastModified > published` guard in the blog route: it could never change the value (getRouteLastModified already returns the max) and its only effect was flipping modifiedTime to undefined, which made unedited posts emit a date-only article:published_time while edited ones emitted a full ISO timestamp. The `?? publishedTime` fallback in createPageMetadata is now the one place that rule lives, and both fields are ISO timestamps on every post. Date parsing moves to the shared `publishedDate()` helper, so a malformed frontmatter date drops the article block instead of shipping NaN-adjacent garbage as article:published_time. getRouteLastModified's post index is now a required `postsByRoute` (renamed: the keys are route paths) rather than an optional argument with a drafts policy that diverged from the caller's, which could return different answers for the same route. Callers build it with the new getPostsByRoute() or pass the post they already hold. Replaces the tautological SITE_NAME assertion with a real scan of src, content, scripts, and e2e for the mis-cased brand, and covers the modifiedTime fallback plus the absence of article keys on non-article pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): schema.org json-ld builders Pure builders for Organization, WebSite, SoftwareSourceCode, BlogPosting, TechArticle, and BreadcrumbList nodes, plus a JsonLd render component. Not mounted on any route yet. Every emitted URL was verified to resolve: the repository is cacheplane/angular-agent-framework (not blove/...), sameAs links the public package page rather than the member-gated npm org page, and the author URL and per-post OG image are omitted until /about and the fixed image route exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(website): make json-ld round-trip assertions real expectSerializable claimed a round-trip but never compared the parsed result to the input, so JSON.parse(JSON.stringify(obj)) could not fail and the six "serializes to JSON" tests were vacuous. Adding toStrictEqual immediately caught blogPostingJsonLd leaving an undefined-valued `keywords` key when a post has no tags, where techArticleJsonLd already used the omit pattern for dateModified; both builders now agree. Also adds table-driven coverage of @context and the Organization @id reference across every builder that carries one, a rootJsonLd() @graph that makes the three root-layout nodes physically inseparable so an @id reference cannot be orphaned by mounting a subset, and an exported BreadcrumbCrumb type. Drops Organization.logo: a 1200x630 marketing social card is not a brand mark, and no square mark exists in the repo. Omitting is honest; the property should be restored once a real mark ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): mount json-ld on layout, blog, and docs Root layout mounts `rootJsonLd()` as a single `@graph`, so the Organization node travels with every page and the `@id` references made by BlogPosting and TechArticle always resolve. Blog posts emit BlogPosting + BreadcrumbList; docs pages emit TechArticle + BreadcrumbList. Both reuse the exact sources their `generateMetadata` already uses, so `dateModified`, `og:modified_time`, and the sitemap `<lastmod>` agree: `getPostLastModified()` factors the single-post date derivation the blog route had inline, and `resolveDocDescription()` exposes the description accessor that `getDocMetadata()` builds the meta description from. The docs breadcrumb links its library rung at that library's introduction page, mirroring the visible <DocsBreadcrumb>; `/docs/<library>` has no route and would have been a crumb pointing at a 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(website): pin breadcrumb and description invariants at their real surfaces The docs-description test was a tautology: this branch made `getDocMetadata` call `resolveDocDescription`, so both sides of the comparison had become the same function and 116 double MDX reads asserted nothing. Replaced with a spec that renders the docs route component and compares the JSON-LD it actually emits against the route's own `generateMetadata` — the surface where divergence can really occur. `libraryIntroPath()` now lives in docs-config and is called by both the visible <DocsBreadcrumb> and the page's BreadcrumbList, so the "markup mirrors the visible trail" claim is structural instead of commented. The new spec asserts the JSON-LD library rung equals the href the rendered component produces, and that every non-final rung is a route the sitemap knows. Also: the sitemap-agreement test no longer passes on two undefineds; the "unmodified" fallback rule collapses into one `resolveModifiedTime()` shared by og and JSON-LD; the docs fallback description and the docs last-modified derivation each get one home; and both page mounts hoist their builder calls above the return. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): stop the per-post OpenGraph image route 500ing Every blog post's social card returned HTTP 500 in production. Satori rejects a div with more than one child node unless it carries an explicit `display`, and the byline rendered three children (author, separator, date) with none. The root `/opengraph-image` route was unaffected only because it is prerendered at build time, while `/blog/[slug]/opengraph-image` is server-rendered on demand. Separately, the route read the bundled Garamond TTF through a parent traversal (`join(here, '../../EBGaramond-Bold.ttf')`) that Next's file tracer cannot statically resolve, so the font was absent from the deployed function's trace — confirmed by diffing the two routes' `route.js.nft.json`. Move font loading into `src/app/og-font.ts`, colocated with the TTF, so the sibling-filename form traces for every caller. Both routes now share it. Make the failure mode safe: `satoriFonts` returns `undefined` rather than `[]` when every font fails, since Satori throws on an empty list but falls back to its bundled Noto Sans when the option is omitted. A plain card beats a 500. With the route verified locally, point `og:image`/`twitter:image` and the BlogPosting `image` at the per-post card, flipping the two TODOs that deliberately waited on this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(website): prerender per-post og cards The per-post card route was `ƒ` (server-rendered on demand), so Satori markup errors could only surface as a production 500 — which is how the missing `display: flex` on the byline shipped and broke all nine cards. The root route escaped the same class of bug purely because it is prerendered, where such an error fails the build instead. Add `generateStaticParams`, mirroring this segment's `page.tsx`, so every published post's card is generated at build time. The route now reports as `●` and emits nine 1200x630 PNGs into the build output. This turns render-time Satori failures into build failures, drops two uncached Google Fonts round-trips and an MDX read per request on a path crawlers hit, and moves `resolveWebsiteDir()` onto the build's cwd — which is known to resolve — instead of an unverified serverless cwd. Also: - Extract `loadCardFonts` so both routes stop hand-assembling the same font descriptors, and widen `OgFont.weight` to the CSS weight domain so call sites no longer need `as const`. - Add a shared `ogImagePath(slug)` used by both `blog/[slug]/page.tsx` and `blogPostingJsonLd`; the two built the same URL independently and could drift with both suites green. - Drop the two defensive `display: flex` values on single-child divs and scope the comment to the byline, which is the one that was diagnosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): keep anchor glyphs out of heading text Docs and blog headings rendered a literal `#` text node before the heading children, so every extracted heading came out as `#Prerequisites` / `#1. Install the packages` — polluting search snippets, page outlines, and anything summarizing the page from the DOM. The `#` is now CSS generated content on `.heading-anchor::before`, and the anchor is rendered after `{children}`. Extracted text is exactly the heading text, while the permalink stays a real link with its `aria-label` and its place in the tab order. Both MDX heading overrides (MdxRenderer and the choosing-an-adapter page, which had its own copy of the same bug) now share one `mdxHeadingComponents` module, covered by a regression spec asserting heading `textContent` carries no `#`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(website): guard the css-generated heading anchor The heading `#` glyph is now CSS generated content, so the visible permalink affordance hangs entirely on one declaration that jsdom cannot see — it does not resolve pseudo-element content. Add a Playwright assertion in the docs spec that reads `getComputedStyle(el, '::before').content` off the rendered anchor and pins it to `"#"`, alongside a check that the heading text carries no glyph. Verified red by commenting out the rule (received "none"). Also label the permalink with the heading text rather than the slug, so it announces "Link to At a glance" instead of "Link to at-a-glance". Children are frequently nested nodes, so the text is flattened recursively and falls back to the id when nothing can be derived. Correct the module comment to name the real selectors: the CSS is scoped to H2/H3 inside `.docs-prose`, not to `.heading-anchor` generally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): add /about page carrying a Person entity Establishes an attributable author for the site: an AboutPage JSON-LD graph whose mainEntity is a Person built from the existing `blogAuthors['brian']` record, referencing the Organization the root layout already mounts. Now that the route exists, blog bylines carry `author.url` pointing at it — deliberately omitted before, when a 404 author URL was the worse signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): unify the author entity across blog and about Every BlogPosting byline now carries the same `@id` the /about Person declares, so the two are one entity to a consumer rather than two nodes that merely share a name — which is the point of the attribution work. `knowsAbout` moves onto the Author record beside `bio`: it is a claim about the person, so it belongs with the person rather than with one page that renders them. Also: /about reads the author through `getAuthor()` for its missing-key fallback, and shares the repository URL constant instead of adding a third copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(blog): question-form section headings in the 2026-05/06 posts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(website): record the no-scaled-content rule for solutions pages Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(website): track ai crawler and ai referral traffic Google Search Console's Generative AI report is UI-only, and AI crawlers never execute JavaScript, so the client PostHog snippet cannot observe either signal. Add edge middleware that classifies the request and emits marketing:ai_crawler_visit / marketing:ai_referral_visit. - ai-traffic.ts: pure classifiers. Crawler UAs are matched on published tokens (plain Googlebot/Applebot deliberately excluded); referrers are matched on the parsed hostname exactly or as a subdomain, so lookalikes such as evil-chatgpt.com.attacker.net do not classify. - Capture goes direct to the PostHog ingest host over fetch, not through posthog-node (a Node library; middleware runs on the Edge runtime) and not through the /ingest/* rewrites, which exist for the browser. - Registered with FetchEvent#waitUntil so the response is never delayed, with a 2s abort: a dropped event is acceptable, a hung request is not. - Anonymous by construction: $process_person_profile false, $ip null, pathname only (never the query string), and no UA on referral events. - Crawler events are deduped per crawler/path/hour, best-effort and per-instance, so a crawler looping one URL cannot become a firehose. - Matcher excludes api, _next, ingest, og/twitter image routes and any path whose last segment has an extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): bound ai-traffic event volume and cover the capture payload Both inputs to this feature are attacker-controlled, and the per-key dedup was not an abuse ceiling: varying the path defeats it entirely, and the referral path had no limiter at all. Demonstrated live — 700 requests with a spoofed chatgpt.com Referer produced 700 events. Anyone reading the bundle could bill the PostHog account arbitrarily or poison the dataset this feature exists to produce. - Add a per-instance token bucket on TOTAL emissions (500/hour, burst 500), so the blast radius is bounded regardless of key variety. Chosen well above honest volume: 141 sitemap URLs, and a simultaneous full-site sweep by three crawlers landing on one instance is ~423. Per-instance and best-effort like the dedup: it bounds blast radius, it is not a global quota. - Cover sendToPostHog, where every privacy invariant lives. Asserts the exact wire payload for both event types: $process_person_profile false, $ip null, no query string, no referrer URL, no UA on referral events, and that a caller cannot override the anonymous properties. - Observe /llms.txt, /llms-full.txt, /sitemap.xml and /robots.txt. These are the file written for AI consumers and the strongest crawler-intent signals that exist; losing them to the blunt extension rule undercut the feature's own premise. - Anchor the og/twitter-image exclusion to a segment boundary, so a page named /my-opengraph-image-guide is no longer silently dropped. - Rename middleware.ts to proxy.ts (Next 16 deprecates the middleware file convention). NextProxy is NextMiddleware, so waitUntil is unchanged. This moves the runtime from edge to nodejs, re-verified below. - Dedup key is now JSON.stringify([bucket, crawler, path]): a path may legitimately contain any delimiter, and a collision silently drops an event. Drop keepalive (a browser-unload primitive; waitUntil is what holds the invocation). Drop the unreachable '[::1]' branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * revert(website): keep ai-traffic middleware on the edge runtime Reverts only the proxy.ts rename from 3413824. Every other change in that commit — the emission token bucket, the sendToPostHog payload coverage, the llms.txt/sitemap.xml/robots.txt matchers, the anchored og-image exclusion — is kept. The rename silenced a cosmetic deprecation warning at the cost of moving the runtime from Edge to Node on every request to a public production site. Next 16's proxy convention always runs on Node, so `git mv` was not a file rename but a deployment-shape change: different cold-start and pricing characteristics on Vercel, and unverifiable from a local build. The deprecation is a warning, not a break, and 16.1.7 builds and runs the middleware convention fine. The migration is still worth doing — it additionally makes process.env runtime-read instead of build-inlined, so token rotation would stop needing a redeploy — but it belongs in its own change with a preview deploy behind it, not bundled in as a side effect of a warning fix. A comment on the file records that the warning is deliberate and that the migration is mechanical, because the code uses only Web-standard APIs. Re-verified on Edge after the revert, rather than assumed to carry over from the Node run: chunks back under server/edge/ with all five matchers; a 5s-hanging PostHog left /contact, /llms.txt and /pricing returning 200 in 9-69ms with all three events still delivered; two 700-request floods (spoofed chatgpt.com Referer, and GPTBot across 700 distinct paths) each capped at 501 events; llms.txt, llms-full.txt, sitemap.xml and robots.txt all captured; ordinary traffic, Googlebot, a google.com referrer and the evil-chatgpt.com.attacker.net lookalike all emitted nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(blog): architecture diagrams for the tutorial posts Every blog post shipped zero images. Add three hand-authored SVG diagrams, one per architecture-heavy post, each drawn from what the post actually explains: - ag-ui-event-flow: the client-tool round trip — the browser ships its tool catalog up, the server streams AG-UI events down, and the tool result the browser produces starts the next run. - langgraph-threads-and-runs: the ACTIVE_THREAD signal as the pivot between the thread list and the active run. - agent-contract-boundary: two runtimes reduced into one neutral contract of Angular signals, with user intent travelling back. Each SVG paints its own surface and carries a <title>/<desc>, so it reads on a light or dark page without depending on the host theme. Colors come from the design tokens' light palette. Add an `img` override to MdxRenderer that emits explicit width/height (so the box is reserved before the file loads, keeping layout shift at zero) plus lazy loading and async decoding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(gtm): ai search measurement runbook The GSC harness README and every generated report already point at this file. Write it. Covers what each source can and cannot answer, the monthly routine over gsc:pull / gsc:report, the AI crawler and referral events emitted from Edge middleware, and the baseline from the first real pull (2026-05-19 → 2026-08-17) so later pulls have something to compare to. Two things it is deliberately blunt about: - The Search Console Generative AI performance report is UI-only. It is not in searchanalytics.query, not a searchAppearance value, and not in the BigQuery export. The only way to read it is by hand, so the doc carries the manual procedure and a running log to paste it into. - Query-dimension totals (528 impressions) are lower than page-dimension totals because Google anonymizes rare queries. Page-level is the true volume. Plus a do-not-do list — llms.txt as a Search tactic, content chunking, AI-specific keyword rewrites, inauthentic mentions — so nobody re-adds them. We keep /llms.txt because some non-Google assistants read it, not because it helps Google. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(blog): size diagrams for the real prose column The diagrams were authored at 880 wide. `.docs-prose` computes to 706.56px — `max-width: 70ch` wins over Tailwind's `max-w-none` because it sits later in the built stylesheet — so they rendered at scale 0.803 and put 12.5px labels at 10.0px. On a 375px viewport the column is 323px, scale 0.372, detail text at 4.6px. Unreadable, and never measured. Re-author all three at a 700 viewBox so they render 1:1 in the column, with the type scale raised a step (meta 13px, eyebrow 11.5px). The wider layouts do not survive 700px, so each one goes more vertical: - ag-ui-event-flow: one column of six steps, browser/server marked by a tinted left rule and a corner tag, with the return rail on the left. - langgraph-threads-and-runs: two rows pivoting on ACTIVE_THREAD, edge labels moved above each exchange instead of into the column gap. - agent-contract-boundary: four stacked bands instead of four columns. Below 706px the paragraph scrolls rather than the figure shrinking — the same treatment `.docs-table-scroll` gives a wide table — so labels never scale below their authored size. Verified: the page itself does not gain a horizontal scrollbar at 371px. Also drop the inline `style` from the img override in favour of a `.docs-diagram` class. It duplicated `.docs-prose > p > img`, silently overrode that rule's `margin: 2rem auto`, and being inline would have clobbered any author-supplied `style` on a future markdown image. Per-diagram intrinsic sizes replace the single shared constant now that the three differ in height. Measured effective font size of the smallest text class, in the built page: 11.5px at 1280px wide and 11.5px at 375px (scale 1.0 at both). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(gtm): scope the dedup claim to crawler events The bullet sat under a heading covering both AI events, but `shouldEmitCrawlerEvent` is only called on the crawler path in middleware.ts. The 500/hour token bucket is the one bound both share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: ignore local service-account keys directory Keeps keys/gcp.json (Search Console service account) out of git. Also added to .git/info/exclude so it takes effect in every worktree immediately, not only after this branch merges. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 13d8a87 commit e50c31b

68 files changed

Lines changed: 6592 additions & 183 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,6 @@ libs/licensing/src/lib/license-public-key.generated.ts
6767
examples/ag-ui/angular/src/environments/generated-keys.local.ts
6868
# Chat example generated API keys (injected from .env at build time)
6969
examples/chat/angular/src/environments/generated-keys.local.ts
70+
71+
# Local service-account keys (GSC, etc). Never commit these.
72+
keys/

apps/website/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.gsc/

apps/website/content/blog/2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Most AI chat features still ship without streaming — they buffer the full resp
1717
- Wire a real LangGraph backend to the UI without writing any transport code.
1818
- Cover the three production patterns that matter once the scaffold works: errors, threads, and generative UI.
1919

20-
## Why streaming matters
20+
## Why does streaming matter?
2121

2222
A user reads at roughly 200 to 300 words per minute; a modern model produces tokens faster than that. If you stream, the user starts reading before the model has finished. If you buffer, every response feels like a page load with no progress indicator.
2323

@@ -160,7 +160,7 @@ The slot pattern is intentional: the chat doesn't set your welcome copy, pick yo
160160

161161
Theming is a separate concern. The chat reads from CSS custom properties — `--chat-bg`, `--chat-fg`, `--chat-accent`, and a few dozen more. If you already use a design system, map your tokens onto theirs in a single stylesheet and the chat picks them up.
162162

163-
## What's happening under the hood
163+
## What's happening under the hood?
164164

165165
Let's peek at the contract. The adapter exposes a small surface, the chat consumes it, and everything else is implementation detail.
166166

apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Three boxes. Two seams.
5757

5858
**The wire.** Server-Sent Events. Plain HTTP, no WebSocket gymnastics, no custom binary framing. Your firewall, load balancer, and reverse proxy already know what to do with it.
5959

60-
**The Angular side.** This is what ThreadPlane provides. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why.
60+
**The Angular side.** This is what Threadplane provides. `@threadplane/ag-ui` is the adapter. It consumes the AG-UI event stream and exposes a runtime-neutral `Agent` contract built from signals. `@threadplane/chat` is the UI. It reads from that contract and renders. The two are decoupled on purpose. We'll get to why.
6161

6262
## Let's wire it up
6363

@@ -152,7 +152,7 @@ No `EventSource`. No reducer. No manual subscribe-and-render plumbing. No store.
152152

153153
Spin up your agent backend, point `url` at it, and the chat just works.
154154

155-
## How AG-UI events become signals
155+
## How do AG-UI events become signals?
156156

157157
The AG-UI protocol has seventeen event types, grouped into five families:
158158

@@ -164,7 +164,7 @@ The AG-UI protocol has seventeen event types, grouped into five families:
164164

165165
The families each do specific work. Lifecycle answers "is something happening?" Text messages are the streaming triad familiar from chat UIs. Tool calls are deliberately incremental so you can render the *intent* before the arguments are fully formed. State sync uses RFC 6902 JSON Patch so the wire stays small even when the agent's state is large.
166166

167-
ThreadPlane's `@threadplane/ag-ui` runs each event through a small reducer that updates a handful of signals on the `Agent` contract:
167+
Threadplane's `@threadplane/ag-ui` runs each event through a small reducer that updates a handful of signals on the `Agent` contract:
168168

169169
- `messages()`: `Message[]`, the chat history. `TEXT_MESSAGE_CONTENT` appends a delta to the in-flight assistant message.
170170
- `status()`: `'idle' | 'running' | 'error' | 'paused'`. Driven by the `RUN_*` events.
@@ -267,7 +267,7 @@ How you scope threads — per project, per task, per user session — is a produ
267267

268268
If you want a starting point, `@threadplane/chat` exposes a `<chat-sidebar>` primitive that handles the layout without locking you into a persistence model.
269269

270-
## Swap the backend without changing the UI
270+
## Can you swap the backend without changing the UI?
271271

272272
This is the part that pays off the protocol bet.
273273

@@ -300,6 +300,6 @@ Each of those is its own post. The point here is just that the protocol-to-signa
300300

301301
## Conclusion
302302

303-
AG-UI standardizes the wire between the agent and the UI: it's small enough to hold in your head, and the event model maps onto Angular signals cleanly. With ThreadPlane (`@threadplane/ag-ui` and `@threadplane/chat` on npm), the wiring is three lines — a provider, an inject, and a `<chat>` — which leaves the interesting work (tool cards, interrupt flows, generative UI, your design system) as the part you spend the day on.
303+
AG-UI standardizes the wire between the agent and the UI: it's small enough to hold in your head, and the event model maps onto Angular signals cleanly. With Threadplane (`@threadplane/ag-ui` and `@threadplane/chat` on npm), the wiring is three lines — a provider, an inject, and a `<chat>` — which leaves the interesting work (tool cards, interrupt flows, generative UI, your design system) as the part you spend the day on.
304304

305305
The adapters are MIT; `@threadplane/chat` is source-available with a free non-commercial tier. If you're building this inside an enterprise Angular app (design system, multi-tenant, regulated), [talk to us](/contact?source=blog_ag_ui_pillar&track=enterprise).

apps/website/content/blog/2026-05-28-human-in-the-loop-langgraph-agents-in-angular.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Everything below is running code from the cockpit example at `cockpit/langgraph/
3030
- Render the approval dialog in Angular with the `<chat-approval-card>` composition.
3131
- Resume, reject, or edit-then-resume — with a distinct path for each.
3232

33-
## When to use an interrupt
33+
## When should you use an interrupt?
3434

3535
Most tool calls don't need approval. Reads, searches, and lookups can run unattended. Reach for an interrupt when a tool does something the operator wouldn't want to undo by hand: moves money, sends a customer-facing message, deletes a record, or triggers a deploy.
3636

apps/website/content/blog/2026-06-04-human-in-the-loop-ag-ui-agents-in-angular.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ That's the whole client-side delta. The rest of the file — the template bindin
4848

4949
`<chat-approval-card>` reads `agent.interrupt()` (a `Signal<AgentInterrupt | undefined>`), and `submit({ resume })` is part of the runtime-neutral `Agent` contract declared in `@threadplane/chat`. Both adapters populate the signal and forward the resume; the chat surface above doesn't see the wire format.
5050

51-
## When to use an interrupt
51+
## When should you use an interrupt?
5252

5353
Most tool calls don't need approval. Reads, searches, and lookups can run unattended. Reach for an interrupt when a tool does something the operator wouldn't want to undo by hand: moves money, sends a customer-facing message, deletes a record, or triggers a deploy.
5454

apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ Let the adapter own accumulation, deduplication, and lifecycle transitions.
7878
Let the component read the result.
7979
The [Signals guide](/docs/langgraph/concepts/angular-signals) shows the boundary in practice.
8080

81+
![LangGraph stream chunks and AG-UI SSE events both enter a runtime adapter that owns accumulation, deduplication and lifecycle transitions, and the adapter publishes one Agent contract of Angular signals - messages, status, toolCalls, state, error and interrupt - that chat components and an approved component registry read, with user intent travelling back through the same contract.](/blog/diagrams/agent-contract-boundary.svg)
82+
8183
The tradeoff is that normalization can hide useful runtime detail.
8284
Keep an explicit event escape hatch for information that isn't durable UI state, but don't publish messages or tool calls through two competing sources.
8385
Two sources of truth create timing bugs that are difficult to reproduce and even harder to explain to a user.

apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ A reading list. The user asks the assistant to save something; the assistant cal
4747

4848
Three tools, three different shapes, and the server implements none of them.
4949

50+
![The Angular chat component declares its action, view and ask client tools; the catalog travels to the FastAPI /agent endpoint where bind_client_tools binds it to the model for that run, the graph ends its turn and streams AG-UI events back over SSE, the @threadplane/ag-ui adapter reduces them into Angular signals, and the tool result the browser produces starts the next run.](/blog/diagrams/ag-ui-event-flow.svg)
51+
5052
## How do we get an AG-UI endpoint running?
5153

5254
Install the integration:

apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ Two Angular pieces, and they read from different places.
5151

5252
That split is the thing to hold onto. The agent knows about one conversation. The thread adapter knows about all of them.
5353

54+
![chat-sidenav renders every conversation from LangGraphThreadsAdapter while chat renders the active one from the @threadplane/langgraph agent; selecting a row sets the ACTIVE_THREAD signal, the agent adapter watches that signal and switches conversations, and onThreadId writes a newly created thread id back into it.](/blog/diagrams/langgraph-threads-and-runs.svg)
55+
5456
## How do we get a LangGraph server running?
5557

5658
Let's do the backend first, because the Angular side has nothing to bind to without it.

apps/website/e2e/blog.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ test.describe('Blog landing page', () => {
66

77
// Brand eyebrow + H1
88
await expect(page.getByText('Blog', { exact: true }).first()).toBeVisible();
9-
await expect(page.getByRole('heading', { level: 1, name: /Articles from ThreadPlane/i })).toBeVisible();
9+
await expect(page.getByRole('heading', { level: 1, name: /Articles from Threadplane/i })).toBeVisible();
1010

1111
// Filter row contains the "All" chip in active state
1212
await expect(page.getByText('All', { exact: true })).toBeVisible();

0 commit comments

Comments
 (0)