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

@nannos/embed-sdk

v0.7.0

Published

Embeddable Nannos: headless core (protocol + object registration + client-action) and a styleable React UI kit, distributed as a Shadow-DOM web component.

Readme

@nannos/embed-sdk

Embed the Nannos assistant into any React app. You get three things:

  1. A chat panel (Shadow-DOM isolated, restylable) your users talk to — docked beside the page or dropped into any container you own.
  2. In-form actions — the agent fills/updates the form the user is looking at (apply), points at fields (highlight), moves them (navigate), or reads what they see (read_current_page), through your form layer, gated by human approval. apply and read_current_page are ROUND TRIPS: the turn pauses until the browser reports what actually happened (which fields landed vs. were rejected; the sanitized page snapshot), so the agent never assumes success.
  3. Headless tools — the same agent can read/write your backend via MCP when the answer isn't on screen.

ONE React tree (no mount(), no second root — context flows into the panel), a headless-first API (the host owns all chrome: launcher, placement, pin/dock), and the chat state machine runs on the Vercel AI SDK (useChat over a custom A2AChatTransport that bridges the Nannos A2A-over-socket.io protocol). ai/@ai-sdk/react are exact-pinned, bundled dependencies — hosts never install or version-manage them.

Install

npm install @nannos/embed-sdk

Published to the public npm registry from the nannos monorepo — just release bumps, tags and publishes it together with the services (see Releasing). react, react-dom (>=18) and zod (^4) are peer dependencies: the host supplies them, and there is exactly one copy of each.

Package map

| entry | what | weight | |---|---|---| | @nannos/embed-sdk | core + React layer (provider, hooks, adapter) — what page code imports | light | | @nannos/embed-sdk/core | framework-free kernel: socket transport, object registry, client-actions, zod-form, PKCE auth | light | | @nannos/embed-sdk/react | same React layer as the root, explicitly | light | | @nannos/embed-sdk/transport | framework-free chat engine: A2AChatTransport, message model, stores | light | | @nannos/embed-sdk/panel | the chat UI: <AssistantPanel>, <ShadowPortal>, blocks, hooks, vendored AI Elements | heavy — lazy-load it | | @nannos/embed-sdk/styles.css | the compiled sheet (light-DOM hosts that don't scan the source) | — |

The build is preserveModules: one dist file per module, every shared module exists exactly once across entries (the provider context is a singleton — no cross-entry footguns), and hosts code-split at module granularity. The panel entry is the intended lazy boundary:

const AssistantPanel = React.lazy(() =>
  import('@nannos/embed-sdk/panel').then((m) => ({ default: m.AssistantPanel })),
);

Quick start

1. Provider at the app root

import { NannosProvider } from '@nannos/embed-sdk/react';

<NannosProvider
  config={{
    backendUrl: 'https://console.your-nannos.example', // omit for same-origin console usage
    getToken: () => auth.getAccessToken(),             // or `auth={pkce({...})}` — see Auth
  }}
  navigate={(to) => router.push(to)}   // client-action `navigate`
  highlight={myHighlight}              // client-action `highlight` (host DOM knowledge)
  onApplyResult={(t, {rejected}) => rejected.length && toast.warn(...)}
  onError={(e) => Sentry.addBreadcrumb({ category: 'nannos', message: `${e.type}: ${e.message}` })}
  strings={myStringOverrides}          // i18n — see below
>
  <App />
  <MyPanelSurface />                   {/* host-owned container, see step 2 */}
</NannosProvider>

The provider owns the connection AND the panel state the host reads:

const {
  isAvailable, status, isOpen,
  open, close, toggle,                 // open(prompt?, {sendOnOpen?, displayText?, contextKey?})
  isPinned, togglePinned,
  panelWidth, setPanelWidth,           // + NANNOS_PANEL_WIDTH_VAR / clampPanelWidth for docked layouts
  seededPrompt, clearSeededPrompt,
  core,                                // escape hatch: registry, transport, login/logout
} = useAssistant();
  • open(prompt) DRAFTS by default — the prompt lands in the composer for the user to read and send. sendOnOpen: true sends immediately (for triggers that already carry the user's decision); displayText renders host-authored prompts as a muted context chip; contextKey starts a fresh conversation when the page context changed (campaign:B never lands in a chat about campaign:A).
  • open() is gesture-safe: call it from a real click and the PKCE first-login popup runs inside that gesture — never popup-blocked.
  • Pin/width/open state persists (localStorage/sessionStorage under storagePrefix); the pinned width is published as the CSS variable --nannos-panel-width on the root element, so a docked layout is one line: margin-right: var(--nannos-panel-width, 0px).
  • Cmd/Ctrl+J toggles the panel (shortcut={false} to disable).
  • Outside a provider useAssistant() returns a stable no-op value — pages with "Ask AI" affordances render fine without the integration mounted.

2. The panel, in a container you own

import { AssistantPanel } from '@nannos/embed-sdk/panel';

// Shadow-DOM isolated (default) — for embedding into a foreign design system:
<div style={{ position: 'fixed', top: 0, right: 0, height: '100vh', width: panelWidth }}>
  <AssistantPanel />
</div>

// Light-DOM — for a host that shares the SDK's Tailwind tokens (the console):
<AssistantPanel shadow={false} showConversationList header={false} />

<AssistantPanel> fills its container. Props: shadow (default true), hostClassName (e.g. 'dark' — dark mode keys off the shadow host element), styles (host override sheets — see Restyling), header (false, or your own node), showConversationList, and customHeaders/playground for scoped surfaces. There is NO built-in launcher — the host owns every trigger.

Conversation history comes in two shapes, and you get one of them either way:

  • showConversationList — the list as a permanent sidebar. For a wide surface (the console's full-page chat).
  • Default — the header's history button drops the same list as a popover in the thread's top-right corner, leaving the conversation visible behind it. For a narrow embedded panel, where a sidebar does not fit. Picking a conversation closes it; so does Escape, or a click outside.

The header names the conversation, not the agent — that is what changes as the user moves between chats. An unnamed one reads as thread.newConversation from the strings table, so it translates; panel.title now names the panel region instead. A host that wants its agent's name on screen owns that chrome: pass your own header, and read useAgentName() — the adapter's agentName if set, else the bound sub-agent console-backend named in the handshake, else the name the host publishes at /.well-known/agent-skills/index.json (x-nannos-agent.name), else the A2A handshake's agent name.

On a bound embedded surface the composer already shows which agent answers, next to the page-context label — no host chrome needed. useEmbeddedAgent() returns that binding ({ subAgentId, name, description, organization, revision }) or null off an embedded surface. It is the server's answer, so it holds even when the host serves no well-known index on the page's own origin.

A host that replaces header keeps the overlay by driving it itself: useConversationHistory() returns { available, isOpen, open, close, toggle }.

Each row shows what the conversation is about, not what was typed first: the backend writes a short title plus a one-sentence summary after the first exchange completes, and stamps the page the conversation started on (metadata.page_context, taken from the pageContext your host publishes). The row renders that origin as Campaign Summer sale, with the route as its tooltip. Rows written before this — or still on their first turn — fall back to the streamed last message and show no origin.

The list is the newest 50 conversations — the backend's ceiling, and it has no cursor to page past it. Search filters by title server-side, which is how you reach anything older.

3. Register forms (the in-form loop)

Declare your object types once, derive everything at the call site:

import { createNannosForm, type ObjectTypeRegistry } from '@nannos/embed-sdk/react';

const types: ObjectTypeRegistry = {
  Invoice: { schema: invoiceSchema, singular: 'Invoice', idShape: 'simple-numeric',
             highlightLabels: { dueDate: 'Due date' } },
};
export const useNannosForm = createNannosForm(types);

// per form — id/scope/label derived from the registry + route id:
useNannosForm({ form, type: 'Invoice', id: invoiceId });

(useNannosZodForm remains the low-level hook; useObjectStateAdapter binds non-form state containers.) Validation is per field: a value the agent guessed wrong is skipped while the rest land — wire onApplyResult to surface it.

4. Publish the current page (live context)

Tell the assistant where the user is NOW. The router only gives a path and a page title only gives a display name — neither resolves "this campaign" to an id the agent's tools accept, nor says which tab is open. Pages fill that gap in LAYERS (the Gatana pattern):

import { useAssistant, useNannosPageContext } from '@nannos/embed-sdk/react';

// BASE layer — a bridge watching your router:
useAssistant().setPageContext({ key: location.pathname, title: pageTitle });

// a details page layers the thing on screen on top:
useNannosPageContext({ entity: { type: 'Campaign', id, name } });

// a tab or dialog inside it layers its view state:
useNannosPageContext({ view: { tab: 'targetings' }, visible: rowNames });

Fields (NannosPageContext): key (page identity — required somewhere in the stack), title, breadcrumbs, entity {type, id, name?}, view (active tab/filter/selection — scalars only), visible (names on screen, so "the second one" resolves). Layers merge in mount order — later wins, view merges key by key — then the snapshot is SANITIZED: per-field caps, a secret-key deny list (token, password, apiKey, …), and a ~2k whole-payload ceiling that sheds visible, then view, first. Everything here reaches a model — declare named fields; never spread a fetched object in.

What it does:

  • the composer's context chip follows navigation (hosts that publish nothing keep the old behavior: the chip shows the key the conversation was opened under, frozen for its life);
  • every send — new turn, steer, HITL resume — carries the merged snapshot as metadata.pageContext; the orchestrator renders it as a <current_page> block on the last human message (next to <client_objects>, keeping the cached system prefix stable), so "this page / here / this campaign" resolves;
  • open(prompt) defaults its conversation-scoping contextKey to the live key, so an un-keyed "Ask AI" trigger scopes to the page it was pressed on.

The pull half — page readers. The snapshot above rides every send, so it stays small. When the agent needs MORE (client_action kind read_current_page), it asks — the turn pauses, the SDK answers, the turn resumes — and pages answer through registered readers:

import { useNannosPageReader } from '@nannos/embed-sdk/react';

useNannosPageReader('lineItems', () => rows.map(({ id, name, state }) => ({ id, name, state })));
useNannosPageReader('unsavedForm', () => form.getValues());

key names the field in the agent's answer; the read is asked once, on demand, and sanitized before it leaves the browser (the same secret deny list at every depth, plus size caps that truncate rather than refuse — core/page-read.ts). Declare named slices — never hand over a whole fetched object.

The screen outline. Every read ALSO carries the rendered page as a markdown outline, under the reserved screen key — a visibility-respecting DOM walk (core/screen-outline.ts: headings give levels; real tables AND ARIA grids like MUI X DataGrid become markdown tables; form controls read as their value, passwords never; hidden/unmounted content contributes nothing). So a page with no readers still answers with what the user actually sees. The outline takes the budget the readers leave (floor 1.5k / ceiling 7k chars) and is the part that gets cut if the total lands over the 10k cap. Steer it with two attributes and one marker:

  • data-nannos-ignore — drop an element and everything under it (host chrome, internal nav). The SDK panel is excluded already (shadow boundary).
  • data-nannos-redact — replace an element's content with [redacted]; put it on anything that renders a secret's value.
  • data-nannos-read-root — mark the region worth reading (default: <main>, else <body>).

Open dialogs and toasts (sonner, role="alert") are reported even though they portal outside the root. Set screenOutline={false} on the provider to send only page context + readers.

Which agent runs

An embedded host never names a sub-agent. console-backend binds the OAuth client (azp) of the bearer token to a sub-agent an admin configured in the Nannos console (the embed binding, ADR-0006), activates the user for it on their first connect, and stamps every turn server-side. Anything a page sends under executeOnlySubAgentId is dropped. The agent's prompt, tools, skills, model tier and thinking level are published by the host itself under /.well-known/agent-skills/ and synced into that sub-agent.

Auth

Unchanged from v1 (ADR-0002): supply exactly one of

  • getToken (host-token, recommended): called on every (re)connect; refresh is transparent. The socket AND every REST leg share it.
  • auth: pkce({ issuer, clientId, redirectUri }) (self-login fallback): connect-on-mount is silent (never pops a login); interactive login runs inside useAssistant().open()'s gesture. Serve redirectUri yourself and mount <NannosAuthCallback /> there (or a static HTML page doing the postMessage — see the cockpit's public/nannos-auth-callback.html).
    • Brokered SSOpkce({ ..., idpHint: 'alloy' }): when the nannos IdP brokers the host's own IdP, idpHint is sent as kc_idp_hint, so the popup skips the nannos login page. With a live host SSO session it flashes and closes (no credentials typed), and the first visit creates + links the nannos user. Realm setup is code in rcplus-nannos-keycloak (app/keycloak-client-provisioning, --idp alloy); see ADR-0002 Amendments 4 and 5 (the cockpit does not set it). extraAuthParams appends any other authorize params.
  • Your own NannosAuth — the interface is four methods (getAccessToken, login, isAuthenticated, logout), so a host can put token custody wherever it wants. The cockpit does: its bffAuth keeps the Nannos refresh token in cockpit-backend and getAccessToken() asks the backend for a fresh access token, so the browser never holds a refresh token (ADR-0002 Amendment 5).

useNannosStatus() separates unauthenticated (fix = login) from disconnected (network) — plus connecting | connected | authError.

The chat engine (what's under the panel)

  • A2AChatTransport implements the AI SDK's ChatTransport: one shared socket feeds per-conversation UIMessage chunk streams. Typed parts carry the A2A extras: data-workplan, data-agent-thought, data-activity (persisted), data-task, data-feedback-request (transient).
  • HITL is native tool approval: an input-required interrupt becomes approval-requested dynamic-tool parts (risk badges from _risk_metadata, buttons gated by review_configs); addToolApprovalResponse + the AI SDK's sendAutomaticallyWhen produce exactly ONE resume send with the batched decisions, re-attaching the client-object manifest.
  • Streaming offsets are code points (Python len), never .length — reconnect/replay dedupe survives emoji.
  • Steering: sending while a turn streams routes into the RUNNING turn (never interrupts it); reload-mid-turn resumes via the conversation snapshot, including a pending approval card.

Recomposition (the console's playground is the reference):

import { NannosChatScope, useNannosChat, useConversations,
         Thread, Composer, ApprovalCard, WorkingBlock } from '@nannos/embed-sdk/panel';

<NannosChatScope customHeaders={{ 'X-Playground-SubAgentConfig-Hash': hash }}
                 playground={{ subAgentConfigHash: hash, subAgentName: name }}>
  <MyLayout/>   {/* inside: const chat = useNannosChat(); <Thread chat={chat}/> ... */}
</NannosChatScope>

A default <NannosChatScope> mounted at the host's layout keeps streaming, unread counts and reply toasts alive across navigation; <AssistantPanel> reuses a surrounding scope instead of creating a second one. useSocketEvent(name, cb) is the escape hatch for non-chat server events.

i18n

All chrome strings come from a flat table (NannosStrings, English defaults). Hosts override any subset:

import { nannosStringKeys, type NannosStrings } from '@nannos/embed-sdk/react';
const strings = Object.fromEntries(nannosStringKeys.map((k) => [k, t(`nannos.sdk.${k}`)]));
<NannosProvider strings={strings} …>

Placeholders are single-brace ({label}) so they pass through i18next untouched. The cockpit ships en+de this way.

Restyling (the host theming contract)

  1. themeSheet() (recommended): a typed builder for the override sheet — the NannosTheme interface IS the list of what you can change (autocomplete

    • JSDoc per token), and it handles dark mode's specificity correctly:
    import { themeSheet } from '@nannos/embed-sdk/react'; // eager-safe (also on /panel)
    
    const BRAND = themeSheet({ accent: '#bb448b', accentForeground: '#fff' });
    <AssistantPanel styles={[BRAND]} />

    The first argument applies in both color schemes (it beats the SDK's built-in dark palette); the second refines tokens for dark mode only: themeSheet({ accent }, { background: '#111' }).

  2. Token knobs (what themeSheet writes for you): --nannos-accent, --nannos-accent-foreground, --nannos-radius derive --primary/--ring/--radius — one variable re-brands the palette. Every shadcn token on :host (see theme.css) can also be overridden directly. Raw-CSS gotcha: the SDK's :host(.dark) block outranks a plain :host override once dark is stamped — repeat overrides under :host(.dark) (or use themeSheet, which does).

  3. Override sheets: ShadowPortal/AssistantPanel styles={[css]} are adopted AFTER the SDK sheet — cascade wins, full restyles possible.

  4. Stable selectors: interactive blocks carry data-slot="nannos-…".

  5. Light-DOM mode (shadow={false}): host CSS applies natively (:host sheets from themeSheet do NOT — it's shadow-only); the host's Tailwind build must scan the SDK source (@source '../../embed-sdk/src' + the streamdown dist — see the console's index.css).

  6. Dark mode: put dark on hostClassName (shadow) or a .dark ancestor (light DOM).

CSP & origins

| What | Directive | Origin | |---|---|---| | socket.io (polling + websocket) | connect-src | {backendUrl} and its wss:/ws: | | config discovery + REST legs | connect-src | {backendUrl} | | PKCE login popup → OIDC | connect-src | the issuer origin | | Shadow-DOM styles | none | constructed sheets (adoptedStyleSheets), no style-src needed |

Remote backends must allowlist the host origin (CORS_ALLOWED_CHAT_ORIGINS; exact origins or * patterns such as https://pr-*-riad.d.alloy.ch).

Development

  • npm run dev → the harness at localhost:3000 (proxied to a local console-backend on 5001): env switcher (local/stg/prod + token paste), the real panel in shadow/light/dark/restyle modes, and a raw-transport view for wire debugging.
  • npm test (vitest): kernel + transport (incl. the S1–S5 AI-SDK integration gates run against a scripted wire), stores, provider, i18n.
  • npm run build: preserveModules ESM + d.ts + dist/styles.css.
  • scripts/vendor-ai-elements.mjs re-vendors the AI Elements set (codemod applied automatically; keep local patches minimal).

Working on the SDK from a host app

Inside the monorepo nothing needs linking: console-frontend consumes the SDK through the npm workspace, so it always sees this checkout, and its image is built from source at the very commit just release tags.

Apps in other repos (the cockpit frontend) install the published package. To develop both sides at once, register the host once per machine and link it:

mkdir -p hosts && ln -s /path/to/rcplus-alloy-cockpit-frontend/app hosts/cockpit   # once
just host-link cockpit        # host's node_modules/@nannos/embed-sdk → this checkout
npm run build:watch           # in packages/embed-sdk: re-emits dist on every save

host-link swaps only the installed copy. The host's package.json and lockfile stay untouched, so no machine-local path can reach a commit — and a plain npm install (or just host-unlink cockpit) brings the registry copy back. just hosts shows where every host stands: declared range, lockfile pin, what is in node_modules, and what to do about it.

Once the SDK is released, every registered host is moved onto the new version (just release does it as its last step; just host-bump cockpit does one by hand): range in package.json, lockfile pin and node_modules, all from the registry. Commit those two files in the host. The host now runs the registry copy — link again to keep developing.

Releasing

just release picks the SDK up like any other package: it bumps the version from the conventional commits touching packages/embed-sdk, tags embed-sdk/v<version>, and runs npm publish (which rebuilds dist through prepublishOnly, so the tarball always matches the tag). It needs npm credentials — npm login, or //registry.npmjs.org/:_authToken=… in ~/.npmrc — and checks for them before touching the working tree, because a publish cannot be rolled back the way a commit or a tag can.

After the push it bumps the registered hosts (see above); a host failure is reported with the command to retry, never rolled into the release.

To publish the current version on its own (a retry, or a dry run):

just publish-npm embed-sdk            # skips silently if that version is already up
just publish-npm embed-sdk --dry-run

Integration planning (ontology → client objects → tools → brain) lives in INTEGRATION-PLAYBOOK.md. Known gaps and their history: PENDING.md.