npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agility/vibe-embed

v0.8.0

Published

Embed the Agility vibe-coding chat in a React app — widget lifecycle, a versioned host↔widget protocol, and a provider/hook pair.

Readme

@agility/vibe-embed

Embeds the Agility vibe-coding chat into a React app as an Intercom-style widget: an iframe the host controls, a versioned message protocol between them, and a provider/hook pair so any component can open the chat or read what it's doing.

The first host is Agility's own Content Manager app (React 18, Vite), behind a feature flag.

Install

npm install @agility/vibe-embed     # yarn add @agility/vibe-embed

Requires React >= 18 as a peer. Public npm, same scope and registry as @agility/plenum-ui and the rest — no .npmrc entry and no auth token, in the app or in CI.

Why not GitHub Packages: it requires the package be scoped to the repo owner (@agility), and npm maps a registry per scope, not per package. Pointing @agility at GitHub Packages would send plenum-ui, content-fetch, nextjs, management-sdk and the rest there too, and they'd all 404. Using GitHub Packages for this SDK would mean moving the entire scope.

Use

Mount the provider once at the app shell — behind the flag, so flag-off never renders it:

import { AgilityVibeProvider, useAgilityVibe } from "@agility/vibe-embed"

<AgilityVibeProvider
  widgetUrl="https://vibe-coding.agilitycms.com/widget"
  instanceGuid={instance.guid}
  auth={() => getVibeAuth()}          // called lazily, and again on iframe reload
  context={{ url: location.pathname, section: "content", contentID }}
  onNavigate={(e) => router.push(e.url)}
>
  {children}
</AgilityVibeProvider>

Then anywhere below it:

const vibe = useAgilityVibe()
if (!vibe) return null          // null until the provider has mounted — see below
vibe.toggle()
vibe.state.busy                 // true while a turn is running

The things worth knowing before you wire it up

  • useAgilityVibe() is null on the first render only. The instance is created in an effect, so there is no handle until after mount — guard it. From the render after mount it is live, which means on("ready", …) is reachable from inside the tree. (In 0.1.0 it stayed null until some unrelated state message re-rendered the provider, so ready had usually already fired by the time a child could subscribe. Fixed in 0.2.0.)

  • auth returns the logged-in CMS user's credentials, lazily, and again whenever the iframe reloads — so a short-lived token is fine, and expected. Identity is re-derived server-side from the token presented; nothing trusts a client-claimed user id. Return either the token as a string, or a VibeAuth:

    import type { VibeAuth } from "@agility/vibe-embed"
    
    async function getVibeAuth(): Promise<VibeAuth> {
      return {
        token: await mintManagementToken(),
        // OPTIONAL. The host's AgilityAuthOWIN cookie value, for the few CMS settings that live
        // only on the classic backend (sitemap deployments) and accept nothing else — a management
        // token gets a flat 401 there. Omitting it is a supported state: everything else works, and
        // those screens say so themselves rather than failing.
        //
        // Check you can read it before bothering — if the cookie is HttpOnly, no script can:
        //   document.cookie.split('; ').some(c => c.startsWith('AgilityAuthOWIN='))
        classicSession: readOwinCookie(),
    
        // OPTIONAL, and the cheapest correctness win available to a host. Which Agility backends
        // serve THIS instance. Without it we derive them from the guid's region suffix, which
        // cannot work for an instance whose guid is a plain UUID (they exist, and they are dev
        // instances — a suffix parser reads them as production and every call goes to the wrong
        // environment). In the Manager App both values are already in scope:
        //
        //   const { managerUrl } = useWebsiteInfo()   // per-instance classic root
        //
        // Roots only, https, and Agility's own domains — we validate server-side and ignore
        // anything else, because these decide where the credentials above get sent.
        backends: { classic: managerUrl, management: MGMT_API_ROOT },
      }
    }
  • Options are captured at mount, deliberately. widgetUrl or instanceGuid changing means a different widget, not a reconfigured one — remount with a key when the user switches instance.

  • renderLauncher={false} if the host has its own launcher. Read state.busy/state.open and drive it yourself; the plan calls for the launcher to show when a chat is processing. To translate ours rather than replace it, pass labels — much the cheaper of the two.

  • onActivity fires when the agent changes something: {kind:"done"} once per run (revalidate broadly) and {kind:"changed", entityKind, entityId, …} per entity as it lands (revalidate that one thing). The scoped one is what refreshes an editor the user has open.

  • context is pushed on every change — a SPA route change is a setContext, which is how the agent knows what the user is looking at. It's context, not a request: the agent is told never to act on it by itself. Fields: url, title, locale, section, pageID, contentID; anything else is dropped server-side. section is the coarse "where am I" (content, pages, media, models, …) and is the useful one on the many screens that carry no id at all.

  • The latest context is replayed on the handshake. Set it as early as you like — before the iframe exists is fine, the SDK buffers it and flushes on ready. (In 0.1.0 that first push went nowhere and nothing re-sent it, so a user who loaded a screen and immediately asked "help me with this page" got the locale and nothing else. Fixed in 0.2.0.)

Protocol

Host↔widget messages are versioned and namespaced (PROTOCOL_SOURCE, PROTOCOL_V) and every inbound message is validated by parseWidgetMessage before it reaches state — an iframe on another origin is untrusted input. reduceState is pure and exported, so the host can model widget state without a live iframe (that's what the tests do).

Publishing

pnpm --filter @agility/vibe-embed publish --access public

prepack builds first, files ships dist alone, and publishConfig repoints main/types/exports at dist — on publish only.

In this monorepo the package stays source-resolved (main → src/index.ts), so the dashboard keeps importing TypeScript directly with no build step and no dist in the dev path to go stale. That split is the whole reason publishConfig exists here.

Nothing secret ships: this is browser code that a host serves to its own users. Being on public npm doesn't open the widget to the world either — origin and auth are enforced server-side, and public embedding in customers' apps remains a separate decision (see docs/plans/chat-widget.md).

What changed in 0.8.0

Additive; a host that sends nothing behaves exactly as before.

  • auth gained backends — { classic?, management? }, the Agility roots that serve this instance. We had been deriving both from the guid's region suffix, and that derivation was wrong three ways at once: the dev classic host in our table had stopped resolving (dev moved to publishwithagility.com), us2 was spelled where the region calls itself usa2, and an instance whose guid is a plain UUID has no suffix to read at all — so it resolved as production and every call went to the wrong environment. Live dev instances have UUID guids.

    The Manager App has both values already: useWebsiteInfo().managerUrl is the per-instance classic root (Agility returns it on the me() listing, which is where we now read it server-side too), and the Management root is the one your own calls use. Send roots only — scheme + host, no path.

    Validated server-side against Agility's own domains over https, and silently ignored otherwise: these values decide where the token and the classic session get sent, so an arbitrary host cannot be honoured. That check is enforcement, not ceremony — do not point them at a proxy.

What changed in 0.7.0

Additive; an older host ignores the new field and behaves as before.

  • context gained containerID — the ContentViewID of the content list/container the user is looking at. A list screen carries no contentID (the container IS the subject), so without this "create a post in this list" had nothing to resolve against. The CMS was already computing the id and discarding it, with a comment saying why: the protocol had no field for it.

    What the id means depends on where it sits, and that is the host's own route convention: list-{id} is a list · item-{id} under /pages/page-{n}/ is a component on that page · a bare item-{id} is a standalone single item. Send the page id as well when both are on the route — the pair is what tells a component apart from a single item.

  • Send it for list, item and newlistitem screens. In the CMS's own mapping that is topStackItem.name, and the page id has to come off the ROUTE rather than the stack, because the stack only gives you the top of it.

What changed in 0.6.0

The panel can dock. New capability, nothing breaking — an 0.5.x host shows no toggle and behaves exactly as before.

  • presentation: "popup" | "docked" | "modal" joins state. Popup is the Intercom-style floating panel; docked is a full-height column pinned to the edge, the shape Chrome's own side panel has; modal is big and centred over the whole host page on a dimmed backdrop, for a surface that genuinely cannot fit a chat-width column. Closed is always the launcher, in every mode — this is a presentation of the OPEN panel, not a second widget.

  • modal is transient, and the only one that isn't a preference. A surface asks for the room while it is open and hands it straight back; the mode the user actually chose is restored on exit, and modal is never persisted (reopening the widget as a giant dialog because of a report someone read yesterday would be absurd). Clicking the backdrop leaves it, and the widget is told — which is how "click outside to dismiss" works without the host knowing what is inside its own iframe. The room-making callback reports width 0 for a modal: it covers the page on purpose, so asking the host to also shove its content aside would be wrong twice.

  • The toggle lives in the widget's toolbar, because everything lives in the widget. It posts a request; the host applies it and reports back, and the button renders from the answer. So a host that clamps or declines can never leave the toolbar claiming a shape you aren't looking at. Capability-gated the honest way round: the host announces presentations on the handshake, and the widget draws no button unless docking is genuinely on offer.

  • A restyle, never a re-parent. Moving an iframe in the DOM reloads it, which would drop a streaming run and restart the conversation. root and panel change their own CSS and the iframe never moves — a run mid-swap doesn't blink. This is the whole reason the feature is cheap.

  • DOCKED PUSHES YOUR CONTENT ASIDE — it does not cover it. Side by side is the whole reason to dock rather than float, so pushContent defaults on: the SDK pads document.body by the dock width and restores your own inline value on undock. Docking therefore works with no host code at all. Pass an element if your real scroll container isn't the body:

    <AgilityVibeProvider pushContent={document.getElementById("app-main")!} …>

    Prefer to move your own layout? pushContent={false} and read the width:

    const vibe = useAgilityVibe()
    <main style={{ paddingRight: vibe?.dockedWidth ?? 0,
                   transition: `padding-right ${PRESENTATION_TRANSITION_MS}ms ease-out` }}>

    A number, not a boolean, because the width is clamped host-side — it never takes more than 60% of the window, so "docked" alone doesn't tell you how far to move. PRESENTATION_TRANSITION_MS is exported so your content eases in step with the panel; two durations reads as a glitch.

  • The choice sticks, per instance, in localStorage — and beats defaultPresentation, which is a first impression rather than a standing instruction. Pass rememberPresentation={false} to own the preference yourself. dockWidth sets the column width (default 420).

  • setPresentation(mode) is on the hook too, for a host that wants its own "open docked" affordance: vibe.setPresentation("docked"); vibe.open().

The first user of modal is the widget's own operations report: a four-column audit table cannot be read in a 420px frame, so it asks for the middle of the screen while it is open. Nothing is requested when the host can't offer it — the report just renders narrower and its table scrolls.

What changed in 0.5.1

Cosmetic only.

  • The launcher is 64px, up from 48. Dropping the disc in 0.5.0 also dropped a size cue: the bubble only fills the middle ~70% of its viewBox, so 48px of box read as ~34px of mark and looked undersized on the CMS shell. 64px of box is ~45px of mark — about what a 48px disc launcher reads as.

  • --agent-launcher-size retunes it without waiting for a release, alongside --agent-accent and --agent-bubble:

    [data-agility-vibe] { --agent-launcher-size: 56px; }

    The badge offset is derived from it, so it stays pinned to the bubble's corner at any size rather than drifting into the empty part of the box.

What changed in 0.5.0

Nothing breaking, but the launcher looks different and its states mean something new — worth a look before you ship it.

  • The icon IS the launcher. The white disc is gone — no circle, no border, no busy ring around it. The mark floats on the host's page at 48px with a drop-shadow that follows its own outline, and scales slightly on hover (the only affordance a bare mark can have). The marks say what is happening, so a ring had nothing left to add.

  • The marks are the real design (docs/designs/agility-agent-icons.html in the app repo, Joel 2026-08-13). 0.4.0 shipped an approximation: its "working" dot swung around the icon's centre, where the design has it lapping the bubble's actual outline (SMIL animateMotion + mpath — no CSS primitive follows an arbitrary path the same way in every browser). The bubble is filled (--agent-bubble, default #a9aaa3), the sparkles twinkle, and typing is deliberately spare: bubble plus three centred dots, no triangle and no sparkles, so "responding" reads instantly at launcher size.

  • The states settled:

    | the agent is | the launcher shows | |---|---| | doing nothing | the static mark | | waiting on you | the static mark plus a yellow ! badge | | actually sending a response | the typing mark (bouncing dots) | | thinking, not yet responding | the pulse mark (dot lapping the bubble) |

    So phase decides which working mark shows, and the badge — not an animation — is what says "you're needed". Motion under a needs-you signal would say the opposite of the truth.

  • launcherBadge(state) is exported beside launcherIcon/launcherLabel: {kind: "attention", text: "!"} while waiting, {kind: "unread", text: "3"} for runs that finished unseen, null otherwise. A demand and a piece of news are different facts, so they get different badges — a count can't carry "answer me".

  • launcherIconSvg(kind) is exported too, so a host rendering its own launcher can draw the same artwork rather than reproducing it. Pass a unique number per instance if you render more than one (the pulse's motion path is referenced by id).

  • prefers-reduced-motion now actually stops the pulse. SMIL is not CSS, so animation: none never touched it; the animations are omitted at build time instead and the dot is drawn parked on the bubble, which keeps pulse distinguishable from static.

What changed in 0.4.0

Nothing breaking. phase is additive on the wire; a 0.3.0 widget that never sends it gets the processing mark whenever busy, which is truthful.

  • The launcher wears the branded marks — one Agility chat bubble, three states: static (idle, or waiting on you — the badge is the louder signal there), typing (the reply's text is streaming), and pulse (work happening — tools, builds — including in the background with the panel closed; the queue runs the job either way, so the mark stays truthful). The bubble is white now: the marks are drawn dark-on-light with the Agility yellow. All motion stops under prefers-reduced-motion; the states stay distinguishable because the mark itself differs, not just its animation.
  • state gained phase ("typing" | "processing", present only while busy) — how the widget tells the launcher which working mark fits the moment.
  • launcherIcon(state) is exported for hosts rendering their own launcher — same choice, same precedence (attention → static + badge, then typing, then pulse), so the two can't disagree. Pairs with launcherLabel.

What changed in 0.3.0

Nothing breaking. labels is additive, and every accessibility change sets an attribute the host was not setting.

  • labels — every string the built-in launcher renders, so a multilingual host can translate it instead of turning the launcher off. Optional per field; anything omitted stays English.

    labels={{
      open: t("vibe.open"), close: t("vibe.close"), panel: t("vibe.panel"), busy: t("vibe.busy"),
      attention: (n) => t("vibe.attention", { count: n }),   // a function, not a template —
      unread:    (n) => t("vibe.unread",    { count: n }),   // plurals are the host's grammar
    }}
  • The launcher is accessible. Its name states the action (it says Close once open), aria-expanded / aria-haspopup="dialog" / aria-controls are set, and the panel is a named role="dialog". The badge is aria-hidden and its count is in the button's name in words — a digit in a span is invisible to a screen reader, so the one state the badge exists for was the one state it could not convey. Busy is named too, and the pulse stops under prefers-reduced-motion.

  • launcherLabel(state, labels) is exported for hosts rendering their own launcher — same wording and precedence (attention → unread → busy), so the two can't disagree.

  • The widget now posts activity — {kind:"done"} once per run, and {kind:"changed", …} per changed entity, carrying verb / action / entityKind / entityId / entityName / link / locale. Before this it posted none, so any host onActivity handler was never called. See docs/integration/embed-handoff-0.3.0.md §3.

Still open, and known: there is no way for the widget to ask the host for a fresh token mid-session — auth is only called on ready (G3 auth_expired).

What changed in 0.2.0

Nothing breaking — auth: () => token and a context without section both still work.

  • auth may return { token, classicSession? } as well as a bare token string. VibeAuth and VibeContext are exported so a host can type its own callbacks.
  • context gained section.
  • Fixed: the first context push is no longer lost (it is buffered and replayed on ready).
  • Fixed: useAgilityVibe() no longer stays null after mount, so on("ready", …) is reachable from inside the tree.

(The launcher's hardcoded English aria-label and missing aria-expanded/aria-haspopup, listed here as open in 0.2.0, are fixed in 0.3.0 above.)