@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-embedRequires 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@agilityat GitHub Packages would sendplenum-ui,content-fetch,nextjs,management-sdkand 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 runningThe things worth knowing before you wire it up
useAgilityVibe()isnullon 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 meanson("ready", …)is reachable from inside the tree. (In 0.1.0 it stayed null until some unrelated state message re-rendered the provider, soreadyhad usually already fired by the time a child could subscribe. Fixed in 0.2.0.)authreturns 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 aVibeAuth: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.
widgetUrlorinstanceGuidchanging means a different widget, not a reconfigured one — remount with akeywhen the user switches instance.renderLauncher={false}if the host has its own launcher. Readstate.busy/state.openand drive it yourself; the plan calls for the launcher to show when a chat is processing. To translate ours rather than replace it, passlabels— much the cheaper of the two.onActivityfires 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.contextis pushed on every change — a SPA route change is asetContext, 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.sectionis 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 publicprepack 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.
authgainedbackends—{ 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),us2was spelled where the region calls itselfusa2, 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().managerUrlis the per-instance classic root (Agility returns it on theme()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.
contextgainedcontainerID— the ContentViewID of the content list/container the user is looking at. A list screen carries nocontentID(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 bareitem-{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,itemandnewlistitemscreens. In the CMS's own mapping that istopStackItem.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"joinsstate. 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.modalis 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, andmodalis 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
presentationson 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.
rootandpanelchange 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
pushContentdefaults on: the SDK padsdocument.bodyby 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_MSis exported so your content eases in step with the panel; two durations reads as a glitch.The choice sticks, per instance, in
localStorage— and beatsdefaultPresentation, which is a first impression rather than a standing instruction. PassrememberPresentation={false}to own the preference yourself.dockWidthsets 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-sizeretunes it without waiting for a release, alongside--agent-accentand--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.htmlin 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 (SMILanimateMotion+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
phasedecides 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 besidelauncherIcon/launcherLabel:{kind: "attention", text: "!"}while waiting,{kind: "unread", text: "3"}for runs that finished unseen,nullotherwise. 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-motionnow actually stops the pulse. SMIL is not CSS, soanimation: nonenever 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. stategainedphase("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 withlauncherLabel.
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-controlsare set, and the panel is a namedrole="dialog". The badge isaria-hiddenand 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 underprefers-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, carryingverb/action/entityKind/entityId/entityName/link/locale. Before this it posted none, so any hostonActivityhandler 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.
authmay return{ token, classicSession? }as well as a bare token string.VibeAuthandVibeContextare exported so a host can type its own callbacks.contextgainedsection.- Fixed: the first
contextpush is no longer lost (it is buffered and replayed onready). - Fixed:
useAgilityVibe()no longer staysnullafter mount, soon("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.)
