@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-sdkreact (>=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/objectsurface and are dropped from it. If you need one of those, import it from its generated@unifyapps/networkmodule 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:
vite build— ESM-only library build, one entry per subpath in the table above.@unifyapps/networkis bundled inline (not external);react,react-dom,react/jsx-runtimeand@tanstack/react-queryare kept external. Seevite.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 noexportsmap, so subpath imports resolve through thepathsaliases intsconfig.jsonfor both tsgo and (viavite-tsconfig-paths) the bundle.bundleDts— acloseBundleplugin in the same vite config, so the whole package still builds with a singlevite build.rollup-plugin-dtsbundles one self-contained.d.tsper entry, inlining only the referenced types (no leaking paths, no vendored files). It runs on the repo's own TypeScript rather thanvite-plugin-dts' API Extractor, which can't parse@unifyapps/network's TS-6 source.
pnpm --filter @unifyapps/app-builder-sdk buildOutput 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 buildThe 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:
- Bump the version in
package.json(follow semver). For example:pnpm --filter @unifyapps/app-builder-sdk version patch # or minor / major - Publish.
prepublishOnlyruns the full build automatically, so a clean working tree is enough:
Addpnpm --filter @unifyapps/app-builder-sdk publish--dry-runfirst 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 tspasses (type-check).pnpm --filter @unifyapps/app-builder-sdk buildsucceeds anddist/containsindex.js,hooks/object.js,hooks/workflow.js,copilot.js,copilot.cssand their.d.tssiblings.index.jsandhooks/copilot.jsdo not reference the copilot's chunk — that isolation is the whole reason/copilotis a separate entry, and a stray re-export fromsrc/index.tswould silently hand every consumer a 2.3 MB payload.- The only bare imports left anywhere in
dist/arereact,react-dom,react/jsx-runtimeand@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 packhere andbun add <tarball>in agent-platform'stemplate/app, pointtemplate/app/.envat a backend withAPI_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-runtarball contains onlydist/+README.md.
