@colixsystems/widget-sdk
v0.18.0
Published
Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.
Readme
@colixsystems/widget-sdk
Common widget interface for AppStudio. This package implements the contract that every widget — built-in or third-party, web or native — speaks: a WidgetManifest, a WidgetContext, a property schema, the helper hooks, and the static linter that gates submissions.
See the design reference for the full architecture: docs/architecture/widget-marketplace.md, specifically section 3.1.
Status
v0.18.0 — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is not yet published to npm.
What's new in 0.18.0
REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission management from inside a widget.
useRecordPermissions(tableId, recordId)is wired + the newWidgetContext.recordPermissionsslice + the newacl.write:recordsscope. Returns{ permissions, loading, error, grant, revoke, update, refetch }wherepermissionsisArray<{ id, principalType: "USER" | "GROUP" | "PUBLIC", principalId, canRead, canWrite, canDelete, canGrant }>. Hits the existing REQ-ACL-06/api/v1/tables/:tableId/records/:recordId/permissionsREST surface; the host normalises the wire shape (userId/groupId) into theprincipalType+principalIdpair the hook returns. Rejections fromgrant/revoke/updatesurface as a new structuredPermissionError(named export) withcode∈FORBIDDEN | VALIDATION | NOT_FOUND | CONFLICT | INTERNAL. WhentableIdorrecordIdis null/empty the hook collapses to a stable empty no-op result without a network round-trip. Mutating requiresacl.write:recordsANDcanGranton the target record — Studio owners short-circuit, APP_USER actors holdcanGrantvia REQ-ACL-05 (creator-grant) or a delegated REQ-ACL-06 grant. Backs the rewritten built-in Chat widget's "+ New channel" + DM-create flows, where the channel creator grantscanRead/canWrite/canGrantto each invitee without going through Studio. Additive.CONTRACT.version→1.8.0(additive: one new hook, one new context slice, one new scope, one new error class). No existing export changed signature.
What was in 0.17.0
REQ-WBLT-FORMBUILDER — a runtime schema resolver so widgets can render by column type.
useDatastoreSchema(tableId)is wired + the newWidgetContext.datastore.schemaslice. Returns{ schema, loading, error, refetch }whereschemais{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }(nulluntil loaded). It reads the existing ACL-gatedGET /api/v1/tables/:id— structure only, never row data — so a public-grant table resolves for anonymous visitors exactly like a record read. Use it to resolve a storedcolumnIdto its column name / dataType / relation target at runtime (the built-in Form Builder uses it to render an input per column type). Reads need thedatastore.read:<table>scope. Backed by a newdatastore.schema(tableId)host facade (web:widgetHostDatastore; native:hostDatastorein the export'swidgetHost.js). Additive.CONTRACT.version→1.7.0(additive: one new hook, one newdatastore.schemacontext field). No existing export changed signature.
What's new in 0.16.0
REQ-THEME — the tenant's Theme Settings now flow all the way into useTheme().
themeTokens.colorsgainssecondary+onSecondary.useTheme().colors.secondaryreflects the tenant's Secondary Color picker (withonSecondaryas its readable contrast color), alongside the existingprimary/onPrimary. Built-in widgets like Button use it for their secondary variant; third-party widgets can use it for a branded second accent. The fullcolorsshape is now{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }.colors.primary/colors.secondary/typography.fontFamilyare tenant-resolved. The host maps the Studio Theme Settings blob (Primary Color, Secondary Color, Global Font) onto the default tokens before handing them touseTheme(), on both the live Player and the exported app — so a widget that reads tokens re-themes automatically. (Custom Google fonts render in the Player today; the exported app falls back to the system face for non-system fonts until font bundling lands.)CONTRACT.version→1.6.0(additive: two newthemeTokens.colorskeys). No existing export changed signature.
What's new in 0.15.0
REQ-WSDK-PLATFORM — the "split-implementation + vetted package list" pivot. See docs/design/req-widget-sdk-cross-platform-primitives.md for the full design and rationale.
CONTRACT.vettedImports(new). A curated allowlist of bare specifiers a widget may import —react,@colixsystems/widget-sdk,react-native,axios,date-fns,react-native-svg,lucide-react-native,react-native-maps,leaflet,react-leaflet,expo-av,@react-native-community/datetimepicker,expo-clipboard,expo-haptics. Each entry carriesplatforms(one or both of"web"/"native") and acategoryso the linter and the marketplace listing can render honest platform badges.CONTRACT.allowedBareImports(the existing field) is now derived fromvettedImportsand stays a plainstring[]for back-compat.fetchandXMLHttpRequestcome offCONTRACT.bannedApis. Widgets may call third-party APIs directly. Calls to the host's own/api/*surface will 401 because the JWT token is never shared with widget code; the linter emits a softno-host-api-urlwarning when it sees host-URL substrings so authors learn the rule statically. Use SDK hooks (useDatastoreQuery,useUsers,useFile, …) for workspace data; useaxios/fetchfor third-party APIs.import-not-vettedlinter rule (new). Every bareimportspecifier is validated againstCONTRACT.vettedImports. Relative imports inside the bundle (./shared.js) are allowed so split-impl widgets can share helpers;../and absolute paths are rejected.import-platform-mismatchlinter rule (new). A single-source widget that imports a native-only package whilemanifest.supportedPlatformsincludes"web"fails the lint. The author either drops the platform from the manifest OR ships awidget.web.jsx+widget.native.jsxpair where the platform-specific import lives in the file that targets its platform.- Lint findings carry
severity."error"(default) blocks publish;"warning"(currently onlyno-host-api-url) surfaces to reviewers without blocking. ThelintSource(...)return shape stays{ ok, findings }—okis true iff no error-severity findings exist. - Four Tier A SDK additions:
<Icon>primitive —<Icon name="check" size={16} color={theme.colors.primary} />. Wrapslucide-react-native; works on both platforms.<DateTimePicker>primitive —<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />. Wraps@react-native-community/datetimepickerand normalizes the value to ISO 8601 strings (the datastore wire format).useClipboard()hook —{ copy, paste, hasContent }. Web vianavigator.clipboard; native viaexpo-clipboard. Rejections are a structuredClipboardErrorwith.codeinPERMISSION_DENIED | INTERNAL.useToast()hook —{ showToast }. The host installs a workspace-themed renderer atWidgetContext.toast.showToast; if omitted, the web variant dispatches anappstudio:widget-toastCustomEvent and native logs to the console.
CONTRACT.version→1.5.0(additive: two new contract fields —vettedImports,hostApiUrlPatterns— two banned APIs removed, two primitives + two hooks added, one optionalwidgetContextShape.toastslot). No existing export changed signature.
What was in 0.14.1
groupRefproperty type (REQ-USERMGMT M4 / §4.8). Authors can now declare{ type: 'groupRef', label: 'Group' }in theirpropertySchemato render a Group picker in the Studio Properties Panel. The widget receives a bareAppUserGroupUUID — REQ-GEN-07 compliant, so tenant-copy walks the value transparently. Used by the built-inappstudio.user-managementwidget for itsdefaultGroupIdprop and available to third-party widgets that need to anchor behaviour on a specific group.- Patch bump — additive enumeration entry, no exported function signature changed.
CONTRACT.versionstays1.4.0.
What's new in 0.14.0
useUsers()+useGroups()— AppUser administration hooks (REQ-USERMGMT / REQ-ACL-SYS M3). A widget can now invite, deactivate, reactivate, and remove members, and create / delete groups + add / remove members, from a published-app surface. Returns{ users | groups, loading, error, refetch, ... }plus imperative mutation methods. Reads gated byusers.read:*/groups.read:*; mutations byusers.write:*/groups.write:*. Rejections surface as a structuredDirectoryError(new named export) withcode∈FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY. The host signs anX-Widget-Scopesheader againstJWT_SECRETso an APP_USER cannot forge a scope set, and the backend additionally gates the request behind a SystemAclusers.*/groups.*capability grant (REQ-ACL-SYS M1 + M3).- Managing app users from a widget — see the section below.
- New linter rule
no-scope-mismatch-useUsers/no-scope-mismatch-useGroups. CallinguseUsers().invite()/.deactivate()/.reactivate()/.remove()withoutusers.write:*in the manifest'srequestedScopesfails the lint; callinguseGroups()mutation methods withoutgroups.write:*fails the lint. Keeps the manifest honest at submission time. CONTRACT.version→1.4.0(additive: two new hooks, two new context slices, six new scope verbs, one new error class). No existing export changed signature.
What's new in 0.13.0
WidgetTreecomponent +useChildRenderer()hook wired. Container widgets (Tabs, Card, …) can now render arbitrary author-authored child page-tree nodes through the SDK without importing the host renderer. The host pre-binds the surrounding render context (breakpoint, page ctx, parent) into a closure onctx.renderer.renderNode(node)— the widget just passes the child node. Additive.
What's new in 0.12.0
useDatastoreRecord(tableId, recordId)is wired. Returns{ data, loading, error, refetch }for a single record fetched through the host'srecords(table).get(id). Sister touseDatastoreQuery; mirrors its ref discipline sorefetchstays a stable callback identity. A 404 surfaces asDatastoreError.code === "NOT_FOUND". Additive.useFile(fileId)is wired + newWidgetContext.filesslice. Returns{ url, file, loading, error, refetch }— theurlis an absolute URL the widget can drop straight into<Image source>. Backed by a newfiles.get(fileId)host facade (web:widgetHostFilesthroughapi/client; native:hostFilesin the export'swidgetHost.js). Additive.
What's new in 0.11.0
useNavigation()is wired. Returns the host-provided navigation surface{ goTo, goBack, push, replace, back, currentRoute }for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.Linkingprimitive re-exported.Linking.openURL(url)opens an external URL with the OS handler — web (react-native-web) maps towindow.open/location.href; native hands off to the system. Use this for external URLs; useuseNavigation().goTo(pageId)for internal pages.
What's new in 0.10.0
useUser()is wired. Returns the active end-user identity{ id, email, displayName, roles, groupIds }from the host-providedWidgetContext.idisnullfor anonymous visitors and on the Studio canvas preview. All fields are guaranteed present (the host fills safe defaults), so widgets read them without optional chaining. Additive — no migration needed for existing widgets.
What's new in 0.9.0
usePayments()— incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns{ requestPayment, getPayment }.requestPayment({ amountCents, currency?, description, metadata?, returnPath? })triggers a one-time charge from the signed-in app user and resolves to{ id, status, checkoutUrl? }: whencheckoutUrlis present the widget opens it (web: navigate; native:expo-web-browser) — the user pays in hosted Stripe Checkout; when absent (the platform's built-in mock provider, the default until Stripe is configured) the charge auto-confirms (status: "PAID").getPayment(id)polls the terminal status. Backed by a newWidgetContext.paymentsslice and gated by the newpayments.charge:appUserscope. No card data ever touches the widget — never collect card fields yourself. The charge settles to the workspace owner; the amount is bounded by a platform per-charge cap. Rejections are a structuredPaymentError(also a new named export) with a stable.code.CONTRACT.version→1.2.0(additive: one new hook, one new context slice, one new scope, one new error class). No existing export changed signature.
What was in 0.8.0
useDirectory()— read-only user directory hook (REQ-DIR-01). Returns{ users, loading, error, refetch }where each user is{ id, name, role }. Backed by a newWidgetContext.directory.listUsers(query)slice and gated by the newdirectory.read:usersscope. Use it to build a chat people-list, an @-mention picker, or to resolve an author id to a display name. The host readsGET /api/v1/app/users, which hands non-Studio (Player) callers the reduced{ id, name, role }projection — email and other admin-only fields never leave the server for an app end-user.queryis an optional{ q, role, isActive, limit, offset }(qsubstring-matches the display name;roleis"USER"(default),"INTEGRATION", or"ALL"). Mutating users is not part of the widget surface — the directory is read-only.CONTRACT.version→1.1.0(additive: one new hook, one new context slice, one new scope). No existing export changed signature.
What was in 0.7.0
datastoreTemplateis now part of the public manifest contract.CONTRACT.manifestSchemacarries an optionaldatastoreTemplateentry alongside the existing fields. The TypeScriptWidgetManifestalready declared this since 0.3.0, but the runtime contract did not — the agent's system prompt only generated the field set advertised byCONTRACT.manifestSchema, which silently omitteddatastoreTemplatefrom every AI-generated draft. The mismatch meant most AI-generated DATA widgets shipped without a seeded table, forcing the end-user to hand-build it before the widget would render anything useful. With the schema entry in place, the agent now defaults to including adatastoreTemplatewhenever the widget reads or writes data, matching the no-code experience the platform promises.- Agent system prompt (backend/src/core/services/ai-widget-agent.service.js) gains a dedicated
===== DATASTORE TEMPLATE =====section, an updated DATA-widget example with a template, and a CONVERSATION BEHAVIOUR rule pinning the default. The note-list example (TextInput + create + update + delete) now shows the matching template too, including the column-name-match rule (record.Body↔"name": "Body").
What was in 0.6.0
- Primitives are now React Native through-and-through. Web (Studio + Player) bundles with
react-native-web; native (exported Expo app) uses the realreact-native. The hand-written DOM wrappers inprimitives.jsare gone — both web and native files are now one-line re-exports fromreact-native. The widget API surface is unchanged for the components that already existed (Text,View,Pressable,Image,ScrollView,TextInput), so existing 0.5.0 widgets keep running without source edits. - More primitives available for free.
FlatList,SectionList,ActivityIndicator,Switch, andStyleSheetare now exported alongside the original five. Authors who want them just import from@colixsystems/widget-sdklike any other primitive. - Per-component API docs link out to React Native. The Developer Widgets page and the AI agent's system prompt now point at https://reactnative.dev/docs/ for prop details instead of duplicating them in the SDK contract. Less drift, more breadth (every RN prop —
accessibilityLabel,testID, gesture handlers, …, works without explicit allowlisting). - Adding a new primitive is now a single edit. Append the export name to
primitives.js+primitives.native.js+ theCONTRACT.primitivesarray. No web/native implementation split. - New peer dep.
react-native-web >=0.19.0(optional, marked underpeerDependenciesMeta). The web bundler picks it up via an alias (react-native→react-native-webin the host's Vite / webpack config); the native bundler ignores it.
What was in 0.5.0
TextInputprimitive. Cross-platform controlled text field — web maps to<input type="text">(or<textarea>whenmultilineis true), native maps toreact-nativeTextInput. Props:value,onChangeText,placeholder,multiline,rows,disabled,style. Fixes the gap that forced widgets to fall back to raw<textarea>(web-only). The AI Widget Agent's system prompt now documents this primitive.useDatastoreMutation(...).update(id, partial)is wired. HitsPATCH /api/v1/tables/:tableId/records/:id, which now exists on the backend (REQ-DML-PATCH). Partial-update semantics — only the supplied columns are mutated, the rest of the row is left intact. MANY_TO_MANY relations follow replace-set semantics when supplied; omitting the column leaves the existing links alone. Constraint enforcement (REQ-CONS-04) runs against the merged row, excluding self so a re-affirm doesn't trip its own UNIQUE.
What was in 0.4.1
useDatastoreQueryreturns a stablerefetchidentity. The hook no longer rebinds the underlying callback when the host'sWidgetContextvalue (a fresh object identity on every render in Studio + PageRenderer) changes. Widgets that putrefetchin auseEffectdep array no longer loop.
What's new in 0.4.0
CONTRACTis now a named export. A frozen object literal describing the SDK surface every consumer (LLM system prompt, static analyzer, host builder, contract tests) derives from instead of declaring its own copy. Seedocs/design/ai-widget-contract.mdfor the full design andsrc/contract.jsfor the source. The contract carries:hooks— name, signature, return shape, requiredWidgetContextslices, manifest scopes.primitives— the cross-platform primitive list (names + the React Native component each one backs).manifestSchema— the authoritativeWidgetManifestfield list (withidandminAppStudioVersion, not the legacymanifestId/minAppStudioVer).themeTokens— the defaultuseTheme()payload.widgetContextShape— what the host must populate.bundleExportContract— the two default-export shapes the loader accepts.bannedApis— the global allowlist gate.allowedBareImports—react,@colixsystems/widget-sdk.
useDatastoreQueryis now stateful. Returns{ data, loading, error, refetch }. Previously the hook returned the rawlist(...)promise; widgets that called.map(...)synchronously on the result threw on first render. Migration: replaceconst rows = useDatastoreQuery(...)withconst { data, loading, error } = useDatastoreQuery(...).datais always an array (empty when the table is unbound or loading).DatastoreErroris now a named export. Mutations throw a structuredDatastoreErrorwith.code(VALIDATION/CONSTRAINT_VIOLATION/FORBIDDEN/NOT_FOUND/INTERNAL) and an optional.fieldErrorsmap populated from the host's 422 payload. Widgets can branch onerr.codewithout parsing axios messages.useI18n().tnow honoursfallback.t(key, fallback)returnsfallbackwhen the host's translation table has no entry forkey.
What was in 0.3.0
Additive: WidgetManifest carries an optional datastoreTemplate field. When a tenant installs a widget that declares one, the table set is seeded into their workspace alongside the install. Tables follow the existing built-in template semantics (auto-suffixed naming, REQ-ACL-05 creator-grants, REQ-TEMPLATES-ACL public grants, REQ-ACL-RELINHERIT cross-relation inheritance) and persist when the widget is later uninstalled. See WidgetDatastoreTemplate in src/index.d.ts for the structural constraints — at most 8 tables per widget, 24 columns per table, RELATION columns address siblings by suffix.
Public API
import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@colixsystems/widget-sdk";defineWidget({ manifest, component })— validates the manifest and produces a widget module the host can register.validateManifest(m)/validatePropertySchema(s)/validateProps(schema, props)— shape validation; no third-party deps.useDatastoreQuery,useDatastoreRecord,useDatastoreSchema,useDatastoreMutation,useDirectory,useUsers,useGroups,useRecordPermissions,useFile,useWidgetEvent,usePayments,useTheme,useI18n,useUser,useNavigation,useChildRenderer,useClipboard,useToast— hooks that read from the host-providedWidgetContext(or, foruseClipboard, the platform clipboard API directly).useDirectory(query?)returns{ users, loading, error, refetch }(each user{ id, name, role }) and requires thedirectory.read:usersscope.useUsers(query?)returns{ users, loading, error, refetch, invite, deactivate, reactivate, remove }and requiresusers.read:*(mutations also needusers.write:*); rejections are aDirectoryError.useGroups(query?)returns{ groups, loading, error, refetch, create, remove, addMember, removeMember }and requiresgroups.read:*(mutations also needgroups.write:*).usePayments()returns{ requestPayment, getPayment }and requires thepayments.charge:appUserscope;requestPayment(...)rejects with aPaymentError.useUser()returns the active end-user identity{ id, email, displayName, roles, groupIds }(idisnullfor anonymous / preview).useNavigation()returns{ goTo, goBack, push, replace, back, currentRoute }for internal page navigation — for external URLs use theLinkingprimitive (Linking.openURL(url)).useDatastoreRecord(tableId, recordId)returns{ data, loading, error, refetch }for a single record (data is one row or null).useDatastoreSchema(tableId)returns{ schema, loading, error, refetch }whereschemais{ id, name, columns: [{ id, name, dataType, required, relationType, targetTableId, isIdentification }] }(structure only, no row data) — use it to resolve a storedcolumnIdto its column type at runtime; requires thedatastore.read:<table>scope.useFile(fileId)returns{ url, file, loading, error, refetch }— theurlis an absolute URL composed against the host's API base.useChildRenderer()returns{ renderNode(node) }— container widgets call it to render arbitrary child page-tree nodes (prefer theWidgetTreecomponent for the common case).WidgetTree({ node })— component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.Text,View,Pressable,Image,ScrollView,TextInput,FlatList,SectionList,ActivityIndicator,Switch,StyleSheet,Linking,Icon,DateTimePicker— re-exported fromreact-native(the RN primitives) or implemented in the SDK (Iconwrapslucide-react-native,DateTimePickerwraps@react-native-community/datetimepicker). The web build aliasesreact-nativetoreact-native-webso widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the realreact-nativelibrary.Linkingis a static API (Linking.openURL(url)) — use it for external URLs, and useuseNavigation().goTo(pageId)for internal page navigation. See https://reactnative.dev/docs/ for per-component props.WidgetContextProvider— React context provider that the host (Studio, Player, exported app) wraps widgets with.
Managing app users from a widget
REQ-USERMGMT / REQ-ACL-SYS M3 added two hooks that let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. The hooks are gated by two layers: the widget's manifest must declare the scope (so the static analyzer + the host's signed X-Widget-Scopes header agree), and the calling APP_USER must hold the matching users.* / groups.* capability in the tenant (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares users.write:* but whose caller does not hold the grant gets a DirectoryError with code: 'FORBIDDEN' — surface that to the end-user as a "you do not have permission to do that" message.
import { Text, View, Pressable, useUsers, useGroups } from "@colixsystems/widget-sdk";
export default function MemberManager() {
const { users, loading, invite, deactivate, remove } = useUsers({ q: "" });
const { groups, addMember } = useGroups();
if (loading) return <Text>Loading…</Text>;
return (
<View>
{users.map((u) => (
<View key={u.id}>
<Text>{u.name} — {u.isActive ? "active" : "inactive"}</Text>
<Pressable onPress={() => deactivate(u.id)}><Text>Deactivate</Text></Pressable>
<Pressable onPress={() => remove(u.id)}><Text>Remove</Text></Pressable>
</View>
))}
<Pressable
onPress={async () => {
try {
await invite({ email: "[email protected]", name: "New User", groupIds: [groups[0]?.id].filter(Boolean) });
} catch (err) {
// err.code is one of FORBIDDEN | VALIDATION | NOT_FOUND | INVITE_ONLY
}
}}
>
<Text>Invite</Text>
</Pressable>
</View>
);
}The matching manifest declares the scopes:
{
// ...
requestedScopes: ["users.read:*", "users.write:*", "groups.read:*", "groups.write:*"],
}The host rejects calls whose scope is not declared in the manifest (the SDK linter catches this statically too). Declaring a write scope is also a consent prompt the Studio admin sees at install time — the wider the scope set, the more careful the admin is about granting the install.
Managing per-record permissions from a widget
REQ-ACL-06 / REQ-ACL-RELINHERIT-05 added useRecordPermissions(tableId, recordId), the in-app surface for sharing a single record with another user or group. The chat widget uses it to invite members into a channel — the channel record's per-record grants ARE the membership list (Messages inherit those grants via REQ-ACL-RELINHERIT). The hook also covers project-workspace, document-sharing, and team-roster widgets that grant access on a record-by-record basis.
import { Text, View, Pressable, useRecordPermissions, PermissionError } from "@colixsystems/widget-sdk";
export default function ShareRecord({ tableId, recordId, partnerUserId }) {
const { permissions, loading, grant, revoke } = useRecordPermissions(tableId, recordId);
if (loading) return <Text>Loading members…</Text>;
return (
<View>
{permissions.map((p) => (
<View key={p.id}>
<Text>{p.principalType}:{p.principalId} — {p.canWrite ? "writer" : "reader"}</Text>
<Pressable onPress={() => revoke(p.id)}><Text>Remove</Text></Pressable>
</View>
))}
<Pressable
onPress={async () => {
try {
await grant({
principalType: "USER",
principalId: partnerUserId,
canRead: true,
canWrite: true,
canGrant: true,
});
} catch (err) {
if (err instanceof PermissionError && err.code === "FORBIDDEN") {
// The signed-in user lacks canGrant on this record.
}
}
}}
>
<Text>Invite partner</Text>
</Pressable>
</View>
);
}The manifest declares the matching scope:
{
// ...
requestedScopes: ["acl.write:records"],
}The server-side gate is canGrant on the target record — Studio owners short-circuit, and APP_USER actors who hold canGrant (via REQ-ACL-05 creator-grant, or a delegated REQ-ACL-06 grant) pass. A caller without canGrant receives PermissionError { code: "FORBIDDEN" }. The hook collapses to a stable no-op when tableId or recordId is null/empty — so a widget can render its picker first, then bind to the picked record without any conditional hook tricks.
Linter
npx appstudio-widget lint path/to/widget.jsScans for banned patterns (eval, new Function, dynamic import(), direct imports of host stores, raw axios). Exits 1 on findings.
import { lintSource } from "@colixsystems/widget-sdk/linter";
const report = lintSource(source);Why no TypeScript dependency?
The package is plain ESM JavaScript with hand-written .d.ts ambient types. Consumers using TypeScript get full IntelliSense; consumers using JavaScript pay nothing.
