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

@unifyapps/app-builder-sdk

v0.6.0

Published

React hooks and a copilot for UnifyApps app-builder apps — objects, workflows, auth, uploads, and chat against the current origin's session.

Downloads

5,055

Readme

@unifyapps/app-builder-sdk

React Query hooks for the UnifyApps API — records (entity instances), object definitions (entity types), and workflows.

The hooks are the orval-generated ones from @unifyapps/network, re-exported and bundled inline at build time so the published package is self-contained: it does not vendor network's source and does not require consumers to install @unifyapps/network.

Built for same-domain apps: requests go to the current origin and rely on the page's _at session cookie (credentials: 'include'). There is no client to construct, and you don't set up React Query yourself — mount AppBuilderProvider once and it wires up @tanstack/react-query for you.

Install

pnpm add @unifyapps/app-builder-sdk

react (>=18.3.1) is the only peer dependency. @tanstack/react-query ships as a dependency of this package (kept external in the bundle but installed for you), so consumers don't need to add or configure it.

Usage

Wrap your app once with AppBuilderProvider (it mounts React Query), then call the hooks anywhere below it:

import { AppBuilderProvider } from '@unifyapps/app-builder-sdk';

export function App({ children }) {
  return <AppBuilderProvider>{children}</AppBuilderProvider>;
}

AppBuilderProvider with an interfaceId resolves the deployed interface record before mounting its children. Besides the app's public/private security, that record names where a path-hosted app is served (/c/<appId>); the SDK puts that prefix on every /api and /auth call it makes, so nothing in the app has to know about it. Preview builds are exempt.

import {
  useSearchEntities,
  useFindEntityById,
  useCreateEntity,
  useUpdateEntity,
  useDeleteEntity,
  useCreateEntityType,
  useUpdateEntityType,
  useDeleteEntityType,
} from '@unifyapps/app-builder-sdk/hooks/object';
import { useTriggerWorkflow } from '@unifyapps/app-builder-sdk/hooks/workflow';

function Contacts() {
  const { data } = useSearchEntities('Contact', { /* query: filter/sort/page */ });
  const create = useCreateEntity();
  const update = useUpdateEntity();
  const remove = useDeleteEntity();
  const run = useTriggerWorkflow();
  // ...
}

Custom events

useCustomEvent records an event against the app. It shows up in the app's Insights → Events tab, grouped by the name you pass.

import { useCustomEvent } from '@unifyapps/app-builder-sdk';

function Checkout() {
  const sendCustomEvent = useCustomEvent();

  return (
    <Button
      onClick={async () => {
        await placeOrder();
        sendCustomEvent('checkout_completed', { plan: 'pro', items: 3 });
      }}
    >
      Place order
    </Button>
  );
}

Nothing has to be declared first — the dashboard derives its event list from the rows that arrive, so a name you have never sent simply has no row yet. The corollary is that the name is the dimension: rename an event and its history splits into two rows that nothing joins back together.

attributes is stored on the row but is not charted today, so anything you want to see now belongs in the name. Never pass a user or session id — the row already carries both, attributed server-side from the request.

Call it unconditionally. Collection runs only on a deployed, private app with integrations.appAnalytics.enabled on (born true for code apps); everywhere else — preview above all — there is no analytics context and this is a silent no-op. Which also means you will not see your own events in the builder preview: publish the app and open the deployed one.

Realtime

useMqttSubscribe and useMqttPublish move messages between the open copies of an app — another person's browser, or the same person in another tab.

import { useMqttPublish, useMqttSubscribe } from '@unifyapps/app-builder-sdk';

function Orders() {
  const queryClient = useQueryClient();
  const publish = useMqttPublish();

  useMqttSubscribe('order-status-updated', () => {
    void queryClient.invalidateQueries({ queryKey: ['orders'] });
  });

  return <Button onClick={async () => {
    await markShipped(id);
    publish('order-status-updated', { orderId: id });
  }}>Mark shipped</Button>;
}

Pass the bare topic name: the app's own id is joined to it underneath, so two apps using order-status-updated never hear each other — and a message published by the no-code Send MQTT event action or the Send Mqtt Request automation node (carrying this app's id) arrives here.

A message is a hint, not the data. Nothing is retained and nothing is replayed, so a component that mounts a second later, or a tab that was closed, receives nothing. Refetch on a message; never treat the payload as the source of truth, or a tab that missed one is silently wrong from then on.

Call both unconditionally — no provider to add, no guard to write. The connection stays idle until the first hook runs, so an app that never uses realtime pays nothing for it. Like analytics it runs only on a deployed, private app, so you will not see messages in the builder preview: publish and open the deployed app.

Auth (hooks/auth)

Identity providers, session/user, and login/logout for an app protected by an auth layer. The raw generated IdP / session operations are re-exported too; the hooks below are the ergonomic surface. Raw user-context operations (including useGetApiUserContext) live in hooks/user.

import {
  useIdentityProviders,
  useUserContext,
  useAuthLogin,
  useLogout,
  getSSOLoginUrl,
  useSSOLoginUrl,
} from '@unifyapps/app-builder-sdk/hooks/auth';

// Login page — render one option per configured IdP.
function Login({ applicationId }: { applicationId: string }) {
  const { data } = useIdentityProviders(applicationId); // data.objects: IdentityProvider[]
  const login = useAuthLogin();
  // basePath-aware begin-login url — prefixes /c/<appId> on a path-hosted app
  const ssoLoginUrl = useSSOLoginUrl();

  // Keep returnTo RELATIVE (BASE_URL is '/c/<appId>/' path-hosted, '/' at the app's own
  // domain): POST /auth/login rejects an absolute returnTo and falls back to the domain root.
  return data?.objects?.map((idp) =>
    idp.uiConfig?.type === 'button' ? (
      // SSO: full-page redirect to the IdP begin-login endpoint
      <button key={idp.id} onClick={() => { window.location.href = ssoLoginUrl(idp.id!, import.meta.env.BASE_URL); }}>
        {idp.name}
      </button>
    ) : (
      // Password / form IdP
      <button
        key={idp.id}
        onClick={() => login.mutate({ data: { identityProviderId: idp.id!, formData, returnTo: import.meta.env.BASE_URL } })}
      >
        {idp.name}
      </button>
    ),
  );
}

// Anywhere below AppBuilderProvider — read the session and the IdP that authenticated it.
function Profile() {
  const { data } = useUserContext();
  const idp = data?.user?.idp; // branch rendering / API calls on the active IdP
  const logout = useLogout();
  // ...
}

useAuthLogin().mutate resolves with { redirectUrl } — navigate the browser there on success. SSO is a redirect via getSSOLoginUrl. After login/logout, invalidate getGetApiUserContextQueryKey() (from hooks/user, or reload) so the session reflects immediately instead of after a few refreshes.

Already have your own QueryClientProvider? Either skip AppBuilderProvider (the hooks use whatever client is in context) or pass your client: <AppBuilderProvider client={queryClient}>.

Hook names and argument/return shapes follow the OpenAPI operations exactly, since these are the generated hooks as-is (useSearchEntities, useFindEntityById, useCreateEntityType, useTriggerWorkflow, …). On non-2xx responses they throw ErrorType (re-exported from the root entry).

Entry points

| Import | Contents | | --- | --- | | @unifyapps/app-builder-sdk/hooks/object | entity (record) + object-definition hooks | | @unifyapps/app-builder-sdk/hooks/workflow | workflow / automation hooks | | @unifyapps/app-builder-sdk/hooks/auth | identity providers, session, login/logout | | @unifyapps/app-builder-sdk/hooks/user | user-context (current user/session) hooks | | @unifyapps/app-builder-sdk/hooks/upload | useUppy — file upload, returns a stored URL | | @unifyapps/app-builder-sdk/hooks/copilot | useCopilotChat — headless chat with an AI agent | | @unifyapps/app-builder-sdk/copilot | Copilot — the chat UI as a component (heavy, see below) | | @unifyapps/app-builder-sdk/artifact | Artifact — the no-code artifact viewer as a component, by e_artifact_detail id (heavy, shares the copilot stylesheet — load ./copilot.css) | | @unifyapps/app-builder-sdk | all of the above except copilot and artifact, plus AppBuilderProvider, useCustomEvent, useMqttPublish / useMqttSubscribe, AppErrorBoundary and the ErrorType type |

A few operations orval emits into more than one generated module collide on the flat hooks/object surface and are dropped from it. If you need one of those, import it from its generated @unifyapps/network module directly.

Copilot

import { Copilot } from '@unifyapps/app-builder-sdk/copilot';
import '@unifyapps/app-builder-sdk/copilot.css';

<Copilot agentId="aiAgent_xxx" className="h-[600px]" />;

Give it an agent id and it renders the same chat a deployed UnifyApps app does — streamed replies, thought pills, tables, charts, citations, attachments, the canvas. Everything else is optional: chatId to resume a conversation, placeholder, welcomeText, size, variant, filters, allowAttachments, showMessageActions, appearance, onGeneratingResponseChange.

<Copilot /> is the runtime plus the conversation and nothing else — no history sidebar, no header, no drawer. That is deliberate: layout is composition, and composition belongs in the app that owns it, not compiled into this bundle where nobody can edit it.

Compose your own

Conversation history, a New chat button, your own chrome — all of it goes over the top, using the same parts <Copilot /> is built from:

import {
  CopilotProvider,
  CopilotChat,
  CopilotHistory,
  CopilotNewChatButton,
  useCopilotActions,
  useCopilotStatus,
} from '@unifyapps/app-builder-sdk/copilot';

function MyCopilot({ agentId }) {
  return (
    <CopilotProvider agentId={agentId}>
      <MyDrawer>
        <CopilotNewChatButton />
        <CopilotHistory />
      </MyDrawer>
      <main><CopilotChat /></main>
    </CopilotProvider>
  );
}

// Anywhere inside the provider — your own header, your own buttons.
function MyHeader() {
  const { isGenerating, chatId } = useCopilotStatus();
  const { sendMessage, stopResponse, newChat, goToChat } = useCopilotActions();
  // ...
}

| Export | What it is | | --- | --- | | CopilotProvider | the runtime. Everything else must be inside it | | CopilotChat | the conversation — thread, replies, composer. Fills its container | | CopilotHistory | past conversations; picking one switches the chat. Scrolls internally | | CopilotNewChatButton | starts a fresh conversation | | useCopilotActions() | sendMessage, stopResponse, newChat, goToChat | | useCopilotStatus() | isGenerating, chatId |

Selecting a conversation and starting a new one run through the copilot's own goToChat / createNewChat block methods, so renaming, archiving and deleting behave as they do in a deployed app — you wire no callbacks.

agent-platform's template/app/src/components/copilot.tsx is a complete worked example — the composition every generated app starts from.

The parts are not independent components — the copilot is a block on a synthesized page, and each reaches it by id through that page's store. CopilotProvider creates that page, which is why they throw outside it.

Two copilots on one screen is fine: give each its own CopilotProvider. Each mounts its own page store, so their block ids never collide. Pass instanceId to keep their snackbars apart. Never put two CopilotChats under one provider.

Colours

<Copilot
  agentId="aiAgent_xxx"
  appearance={{
    backgroundColor: 'bg-primary',
    messageVariant: 'BUBBLE',
    customStyles: {
      userMessage: { backgroundColor: 'bg-brand-solid', color: 'text-white' },
      brandMessage: { backgroundColor: 'bg-secondary' },
    },
  }}
/>

appearance is forwarded to the block's own appearance, so anything the builder's Appearance panel can style is stylable here. messageVariant: 'BUBBLE' gives every message a filled bubble; DEFAULT leaves agent replies flush against the background. customStyles also covers titlePill, citationsPill, voiceMode and transcript, each taking backgroundColor / borderColor / borderRadius / padding plus typography.

Values are design-system tokens (bg-primary, bg-brand-solid, text-white), not raw CSS colours — they resolve against the theme variables the component scopes to its own root.

It is a component, not a block: no interface, no page config, no block state on your side. Internally it renders the real Copilot with props synthesized from yours, and mounts the app/page providers the runtime needs. See packages/blocks/src/Copilot/standalone.

Unlike the hooks, it needs no AppBuilderProvider — it brings its own React Query client when there isn't one above it, and reuses yours (one shared cache) when there is. Mounting AppBuilderProvider anyway is fine and is what you want if the rest of the app uses the data hooks.

It is browser-only — Next hosts must import it dynamically

import dynamic from 'next/dynamic';

const Copilot = dynamic(
  () => import('@unifyapps/app-builder-sdk/copilot').then((mod) => mod.Copilot),
  { ssr: false },
);

The chunk is built with browser resolution conditions, and some of what it pulls in runs DOM code while the module evaluates rather than while it renders — micromark's decode-named-character-reference resolves to its DOM build and calls document.createElement at module scope. A plain import therefore throws document is not defined during Next's prerender, before any component of yours renders. 'use client' does not prevent this: Next still evaluates client components on the server.

This is not a bug to route around later — a live chat has nothing to render on the server anyway. Vite/CRA and other client-rendered hosts can import it directly.

Two more things to know before you reach for it.

It is big. The copilot entry bundles the no-code runtime and the ~70 block definitions a reply can render — roughly 2.3 MB gzipped. Nothing else in the SDK pulls it: index and hooks/copilot do not touch that chunk, so an app that never imports /copilot pays nothing. But an app that does should expect a step change, not an increment. React.lazy it if the chat isn't on the first paint.

It looks like UnifyApps. The chat renders through Joy with the platform's design tokens, scoped to the component's own root rather than <body>. Dropped into a Tailwind or shadcn app it will look like the platform, not like your app, and Joy's CSS-in-JS ships alongside whatever you already use. Fonts are the exception — it inherits whatever the host has loaded rather than installing its own.

If neither trade is acceptable, use hooks/copilot instead: useCopilotChat gives you the same transport with no UI. The catch is that agent replies carry blocks (tables, charts, citations) that plain text can't represent, and rendering those is exactly what the component is for.

How it's built

Two steps, one command — JS and types are produced by separate tools so network's TS-6 source can be inlined and tree-shaken cleanly:

  1. vite build — ESM-only library build, one entry per subpath in the table above. @unifyapps/network is bundled inline (not external); react, react-dom, react/jsx-runtime and @tanstack/react-query are kept external. See vite.config.ts.

    The copilot entry is why the build runs with a raised heap (--max-old-space-size=8192) — it pulls carbon, blocks, ui and their transitive workspace packages, which is more than node's default budget can hold. Those packages publish no exports map, so subpath imports resolve through the paths aliases in tsconfig.json for both tsgo and (via vite-tsconfig-paths) the bundle.

  2. bundleDts — a closeBundle plugin in the same vite config, so the whole package still builds with a single vite build. rollup-plugin-dts bundles one self-contained .d.ts per entry, inlining only the referenced types (no leaking paths, no vendored files). It runs on the repo's own TypeScript rather than vite-plugin-dts' API Extractor, which can't parse @unifyapps/network's TS-6 source.

pnpm --filter @unifyapps/app-builder-sdk build

Output lands in dist/ (git-ignored). The exports map in package.json points each subpath at its built .js + .d.ts.

Updating the API surface

The hooks come from @unifyapps/network. To pick up API changes, regenerate network then rebuild this package:

pnpm --filter @unifyapps/network gen:api      # regenerate orval hooks from web.yaml
pnpm --filter @unifyapps/app-builder-sdk build

The upload flow comes from carbon — do not re-implement it

hooks/upload is a thin surface over the platform's own uploader. The wire protocol (companion negotiation, S3 vs nfs multipart, getPrivateUrl's asset/path node fallback, the accessScope vocabulary) is shared with apps/platform and apps/matrix and must never fork, so the SDK imports it rather than owning a second copy:

| imported from | what it is | | --- | --- | | @unifyapps/carbon/hooks/useUppy/getUppyInstance | builds the Uppy instance and picks the uploader for the tenant's storage provider | | @unifyapps/carbon/hooks/useUppy/getPrivateUrl | resolves the readable URL after an upload lands | | @unifyapps/carbon/hooks/useUppy/types | the file/meta shapes those two speak | | @unifyapps/defs/types/fileAccessScope | the four permission scopes |

These are deep imports on purpose. Carbon as a whole is not consumable here — it reaches into Joy UI, react-native shims and the no-code runtime — but both packages export ./* → ./src/*.ts, with no index barrel in the way, so a deep import pulls in exactly that module's own dependency graph. For these four that graph is only @unifyapps/network, @uppy/*, lodash and punycode.js: nothing from @unifyapps/ui. Rollup bundles them from source like it does network's.

Keep it that way. Adding an import of a carbon module that touches @unifyapps/ui, react-native, or the no-code store will drag that whole tree into every generated app's bundle — the build will happily do it and nothing will fail loudly. If a carbon module you need isn't clean, lift the portable part of it in carbon rather than importing the dirty module or copying it here.

Everything in src/upload/ (the useUppy hook, the provider, the public UploadedFile shape) is the SDK's own — the small app-facing surface over that flow.

Publishing

The package publishes to the @unifyapps scope on npm (https://registry.npmjs.org/, configured in the repo-root .npmrc). It is a restricted (private-scope) package — see publishConfig.access in package.json. Publishing requires an auth token with publish rights to the @unifyapps scope (provided via the root .npmrc or an NPM_TOKEN env var in CI — never commit a token).

Steps:

  1. Bump the version in package.json (follow semver). For example:
    pnpm --filter @unifyapps/app-builder-sdk version patch   # or minor / major
  2. Publish. prepublishOnly runs the full build automatically, so a clean working tree is enough:
    pnpm --filter @unifyapps/app-builder-sdk publish
    Add --dry-run first to inspect the tarball contents without publishing:
    pnpm --filter @unifyapps/app-builder-sdk publish --dry-run

Only the dist/ output and README.md are shipped (files in package.json); src/, configs and tests are excluded. Verify the tarball with the dry run before a real publish.

Pre-publish checklist

  • pnpm --filter @unifyapps/app-builder-sdk ts passes (type-check).
  • pnpm --filter @unifyapps/app-builder-sdk build succeeds and dist/ contains index.js, hooks/object.js, hooks/workflow.js, copilot.js, copilot.css and their .d.ts siblings.
  • index.js and hooks/copilot.js do not reference the copilot's chunk — that isolation is the whole reason /copilot is a separate entry, and a stray re-export from src/index.ts would silently hand every consumer a 2.3 MB payload.
  • The only bare imports left anywhere in dist/ are react, react-dom, react/jsx-runtime and @tanstack/react-query. Anything else means a dependency escaped the bundle and the package is no longer self-contained:
    # `from "…"` alone is not enough — framer-motion probes an optional package with a bare
    # CommonJS require, which a consumer's dev dep-scanner treats as a missing dependency.
    grep -rhoE 'from "[^".][^"]*"|require\("[^".][^"]*"\)' dist --include='*.js' | sort -u
  • The copilot renders against a live agent. To try a build before publishing, pnpm pack here and bun add <tarball> in agent-platform's template/app, point template/app/.env at a backend with API_PROXY_TARGET, set an agent id, and run the template's dev server — cookies ignore port, so a platform session on localhost carries over.
  • Version bumped and changelog / release notes updated as needed.
  • --dry-run tarball contains only dist/ + README.md.