@colixsystems/widget-sdk 0.147.0 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
47
47
  | **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
48
48
  | **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
49
49
  | **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
50
+ | **AGENTS** | `useAgent(agentRef)` | `{ send, reset, messages, sending, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance). **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. |
50
51
  | **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
51
52
  | **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
52
53
  | **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
@@ -71,7 +72,29 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
71
72
 
72
73
  ## Status
73
74
 
74
- `v0.147.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**.
75
+ `v0.148.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**.
76
+
77
+ ### What's new in 0.148.0 (contract 1.116.0)
78
+
79
+ **New `useAgent(agentRef)` hook — a widget can talk to the WORKSPACE's own AI agent (sc-7991).** A new `agents` host client (`@colixsystems/agents-client`), a new `agentRef` propertySchema type and a new `ai.invoke:*` scope. Everything is additive; nothing existing changes shape.
80
+
81
+ A workspace **agent** is a name plus a system prompt its author wrote in the Studio. This hook lets any widget hold a conversation with one, without touching HTTP and without ever holding an API key.
82
+
83
+ ```js
84
+ const { send, reset, messages, sending, error, available } = useAgent(props.agentId);
85
+ ```
86
+
87
+ **What a widget deliberately cannot do.** You cannot set the system prompt, send your own transcript, or pick a model, and no API surface returns the prompt. The server assembles every turn from the stored prompt plus the stored history, and `content` is the only field that reaches the wire — not because the rest is filtered out, but because nothing else is ever sent. That guarantee is about the WIRE, not about secrecy: a model can be talked into repeating its own prompt, so an agent's prompt is not a secret store — never put credentials, or data the app's users may not see, in one. `messages` is the transcript this widget has seen; the real one is the server's.
88
+
89
+ **Imperative, like `useInterpretDraft`.** It never fires on mount, and the FIRST `send` opens the conversation — so a widget an author places and nobody types into costs the workspace nothing.
90
+
91
+ **Bind it with an `agentRef` property.** Declare `{ type: "agentRef" }` in your `propertySchema` and the Studio renders an agent picker; the value is the agent id you pass to `useAgent`. It is a bare UUID like `pageRef` / `groupRef`, so Copy Workspace carries a bound widget over to the copied workspace's own agent.
92
+
93
+ **Every send spends the workspace's AI credits**, and is rate-limited per caller *and* per workspace. Branch on `AgentError.code`: `AI_QUOTA_EXCEEDED` (429) is the credit ceiling and will **not** clear on a retry — offering "try again" there is worse than useless — where `RATE_LIMITED` (429) will. `AGENT_DISABLED` (409) means the author switched the agent off, so hide the affordance rather than retrying. Never show an app user a raw error or the workspace's billing state: they have not heard of an AI credit and cannot buy one.
94
+
95
+ **`available: false` is a render state, not an error.** It means no agent is bound, or this host brokers no agents client (an unbound canvas preview). Render a visible unbound state — never a blank box.
96
+
97
+ The linter rejects `useAgent()` unless your manifest declares `ai.invoke:*` in `requestedScopes`.
75
98
 
76
99
  ### What's new in 0.147.0 (contract 1.115.0)
77
100
 
@@ -84,6 +107,7 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
107
 
85
108
  **No shipped app moved.** A backfill (`20270123000000_sc8033_footer_tokens_uncouple`) materialised into each workspace's `footer` block exactly what it had been inheriting, and split every stored `activeColor` onto the key that says which half it meant. Nothing is written where the old chain ended in a default the new one also ends in.
86
109
 
110
+
87
111
  ### What's new in 0.146.0 (contract 1.114.0)
88
112
 
89
113
  **The chrome divider's width is bounded in the resolver, so both hosts agree (sc-7965).** `resolveFooterTokens` and `resolveSidebarTokens` returned `borderWidth` as any finite number. Only the export bounded it — on its way into the StyleSheet — so a stored `1e9` ruled a **billion-pixel border across the published web app** and a normal one in the exported app. The clamp moved into the shared resolver, where both hosts inherit it.
@@ -1602,7 +1626,20 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
1602
1626
 
1603
1627
  - **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
1604
1628
  - **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
1605
- - **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`. Use `theme.typography.headingFontFamily` instead on your widget's HEADING-tier text — titles and display numbers — so a workspace that pairs a display face with a body face gets that pairing inside your widget too. It defaults to `fontFamily`, so an unpaired workspace looks identical.
1629
+ - **Style text by its ROLE, not by inventing a size and weight.** `theme.typography.roles` carries the workspace's type scale as six semantic tiers — `display`, `title`, `subtitle`, `body`, `caption`, `overline` — each already resolved to a `fontSize`, `fontWeight`, `fontStyle`, `lineHeight` (a MULTIPLE of the size), `letterSpacing`, `textTransform` and a `colorToken`. Pick the tier the text IS, rather than choosing `fontSize: 16, fontWeight: "600"` yourself: your widget's section title is `title`, each card or row title is `subtitle`, running text is `body`, timestamps and helper text are `caption`, a small kicker or column label is `overline`, and a headline stat is `display`. This is what stops one widget rendering a subtitle bold while the widget beside it renders one italic, and it lets the workspace re-tune every subtitle in the app from Theme Settings in one edit. **Two tokens need converting rather than copying** — `lineHeight` is a multiple (React Native wants absolute pixels, so the raw `1.15` gives you a one-pixel line box) and `colorToken` names a colour rather than being one:
1630
+
1631
+ ```js
1632
+ const r = theme.typography.roles.subtitle;
1633
+ const titleStyle = {
1634
+ fontSize: r.fontSize,
1635
+ fontWeight: r.fontWeight,
1636
+ letterSpacing: r.letterSpacing,
1637
+ textTransform: r.textTransform,
1638
+ lineHeight: Math.round(r.fontSize * r.lineHeight),
1639
+ color: r.color || theme.colors[r.colorToken],
1640
+ };
1641
+ ```
1642
+ - **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place). A role already names its face for you — `display` and `title` take `theme.typography.headingFontFamily`, the rest take `fontFamily` — so a workspace that pairs a display face with a body face gets that pairing inside your widget without you naming a family. `headingFontFamily` defaults to `fontFamily`, so an unpaired workspace looks identical.
1606
1643
  - **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.lg` + `...theme.elevation.sm`. Give it the elevation **or** a `colors.border` hairline, not both — and prefer the elevation, because a hairline-only card reads as a wireframe. `theme.elevation` is a token table you spread into a style (`...theme.elevation.md`), covering `none / sm / md / lg / xl`; never hand-write `shadowOpacity` / `shadowRadius` / `boxShadow`. Use the status roles (`danger / success / warning / info`) for state.
1607
1644
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
1608
1645
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
package/dist/contract.cjs CHANGED
@@ -108,6 +108,58 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
108
108
  headingFontFamily:
109
109
  'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
110
110
  sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
111
+ // REQ-THEME-ROLE (sc-8058): the app's type scale as six SEMANTIC roles.
112
+ // Typography carried two faces and a size scale and nothing else, so a
113
+ // widget needing a "subtitle" had nothing to reach for and each invented
114
+ // its own literal — bold in Notifications, italic in User Management,
115
+ // unweighted in Files. A role names the TIER; the theme owns its look.
116
+ //
117
+ // Which FACE a role takes lives in `_shared/textStyle.js` ROLE_FACES —
118
+ // structural, not themeable, so it resolves without theme data. `colorToken` is
119
+ // the SURFACE token the role is painted with, resolved per render against
120
+ // the surface-aware theme (REQ-THEME-DARK / REQ-THEME-SURFACE) rather than
121
+ // frozen as a hex — a theme may override it with an explicit `color`.
122
+ roles: Object.freeze({
123
+ display: Object.freeze({
124
+ fontSize: 32,
125
+ fontWeight: "700",
126
+ lineHeight: 1.05,
127
+ letterSpacing: -0.5,
128
+ colorToken: "onSurface",
129
+ }),
130
+ title: Object.freeze({
131
+ fontSize: 24,
132
+ fontWeight: "700",
133
+ lineHeight: 1.15,
134
+ letterSpacing: -0.2,
135
+ colorToken: "onSurface",
136
+ }),
137
+ subtitle: Object.freeze({
138
+ fontSize: 16,
139
+ fontWeight: "600",
140
+ lineHeight: 1.3,
141
+ colorToken: "onSurface",
142
+ }),
143
+ body: Object.freeze({
144
+ fontSize: 16,
145
+ fontWeight: "400",
146
+ lineHeight: 1.5,
147
+ colorToken: "onSurface",
148
+ }),
149
+ caption: Object.freeze({
150
+ fontSize: 12,
151
+ fontWeight: "400",
152
+ lineHeight: 1.4,
153
+ colorToken: "onSurfaceMuted",
154
+ }),
155
+ overline: Object.freeze({
156
+ fontSize: 12,
157
+ fontWeight: "600",
158
+ lineHeight: 1.4,
159
+ letterSpacing: 1.5,
160
+ colorToken: "onSurfaceMuted",
161
+ }),
162
+ }),
111
163
  }),
112
164
  // REQ-THEME-15: the tenant's per-component style tokens, folded in by the
113
165
  // host so ONE channel carries them to the Player and the export. Empty by
@@ -172,6 +224,44 @@ const THEME_COMPONENT_TEXT_TRANSFORMS = Object.freeze([
172
224
  "capitalize",
173
225
  ]);
174
226
 
227
+ // REQ-THEME-ROLE (sc-8058) -- the THEME side of the six semantic text tiers, in
228
+ // the order the widgets' own TEXT_ROLES lists them. `themeTokens.typography.roles`
229
+ // carries the seeded values; this names what a TENANT may override, so the Studio
230
+ // control, the Mason `set_theme` coercion and the docs read ONE vocabulary --
231
+ // exactly as `themeComponents` already is for the per-component tokens.
232
+ const THEME_TEXT_ROLES = Object.freeze([
233
+ "display",
234
+ "title",
235
+ "subtitle",
236
+ "body",
237
+ "caption",
238
+ "overline",
239
+ ]);
240
+
241
+ // Mirrors the backend's SITE_HEADER_FONT_WEIGHTS so one theme speaks one weight
242
+ // vocabulary. Strings, because that is what both hosts' `fontWeight` takes.
243
+ const THEME_TEXT_ROLE_FONT_WEIGHTS = Object.freeze([
244
+ "100", "200", "300", "400", "500", "600", "700", "800", "900",
245
+ ]);
246
+
247
+ const THEME_TEXT_ROLE_FONT_STYLES = Object.freeze(["normal", "italic"]);
248
+
249
+ // The per-role token vocabulary, declared like a `themeComponents` scope's
250
+ // `tokens`: each field's value type and bounds stated ONCE, so the Studio
251
+ // control, the set_theme coercion and the host validator cannot disagree.
252
+ // `lineHeight` is a MULTIPLE of `fontSize` (REQ-THEME-13's grammar), so one
253
+ // value is right at every size; `color` overrides the role's surface-aware
254
+ // `colorToken`, which is why no role seeds a literal colour.
255
+ const THEME_TEXT_ROLE_FIELDS = Object.freeze({
256
+ fontSize: Object.freeze({ type: "size", min: 8, max: 96 }),
257
+ fontWeight: Object.freeze({ type: "enum", values: THEME_TEXT_ROLE_FONT_WEIGHTS }),
258
+ fontStyle: Object.freeze({ type: "enum", values: THEME_TEXT_ROLE_FONT_STYLES }),
259
+ lineHeight: Object.freeze({ type: "decimal", min: 0.8, max: 3, step: 0.05 }),
260
+ letterSpacing: Object.freeze({ type: "decimal", min: -2, max: 20, step: 0.1 }),
261
+ textTransform: Object.freeze({ type: "textTransform" }),
262
+ color: Object.freeze({ type: "color" }),
263
+ });
264
+
175
265
  // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
176
266
  // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
177
267
  // variant (a component fill projects through `<Gradient>`, which is linear-only
@@ -1172,6 +1262,47 @@ const HOOKS = [
1172
1262
  requiredContextSlice: ["datastore.interpret"],
1173
1263
  scopes: ["datastore.read:<table>"],
1174
1264
  },
1265
+ {
1266
+ name: "useAgent",
1267
+ signature: "useAgent(agentRef)",
1268
+ description:
1269
+ "sc-7991 — hold a conversation with one of the WORKSPACE's own AI " +
1270
+ "agents (an author-written name + system prompt). IMPERATIVE: call " +
1271
+ "send(text) from an event handler, never on mount — the first send " +
1272
+ "opens the conversation, so a widget nobody types into costs nothing. " +
1273
+ "`messages` is the running transcript, oldest first, each " +
1274
+ "{ id, role, content, created_at } with role 'user' | 'assistant'. " +
1275
+ "You CANNOT set the system prompt, replay an edited transcript, " +
1276
+ "or pick a model. No API returns the prompt — but that is a guarantee " +
1277
+ "about the WIRE, not secrecy: a model can be talked into repeating its " +
1278
+ "own prompt, so never put credentials or hidden data in one. " +
1279
+ "or pick a model: the server assembles every turn from the stored " +
1280
+ "prompt plus the stored history, and `content` is the only thing that " +
1281
+ "goes on the wire. `agentRef` is an agent id an author bound through an " +
1282
+ "`agentRef` property — when it is missing, or the host brokers no " +
1283
+ "agents client, `available` is false and send() rejects UNAVAILABLE; " +
1284
+ "render a visible unbound state, NEVER a blank box. reset() drops the " +
1285
+ "thread and clears the transcript (a 'new chat' affordance). " +
1286
+ "Every send spends the workspace's AI CREDITS and is rate-limited per " +
1287
+ "caller AND per workspace, so call it once per user action. " +
1288
+ "AgentError.code distinguishes the refusals that share a status: " +
1289
+ "AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
1290
+ "NOT clear on a retry — never offer 'try again' for it — where " +
1291
+ "RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
1292
+ "it off, so hide the affordance rather than retrying. NEVER surface a " +
1293
+ "raw error or the workspace's billing state to an app user. " +
1294
+ "Reads ctx.agents.",
1295
+ returnShape: {
1296
+ send: "(text) => Promise<{ id, role, content, created_at } | null>",
1297
+ reset: "() => void",
1298
+ messages: "Array<{ id, conversation_id, role, content, created_at }>",
1299
+ sending: "boolean",
1300
+ error: "AgentError | null",
1301
+ available: "boolean // false when no agent is bound, or the host brokers none",
1302
+ },
1303
+ requiredContextSlice: ["agents.conversations"],
1304
+ scopes: ["ai.invoke:*"],
1305
+ },
1175
1306
  {
1176
1307
  name: "useDatastoreMutation",
1177
1308
  signature: "useDatastoreMutation(tableId)",
@@ -2271,6 +2402,21 @@ const WIDGET_CONTEXT_SHAPE = {
2271
2402
  required: false,
2272
2403
  fields: { params: "object", set: "function" },
2273
2404
  },
2405
+ agents: {
2406
+ description:
2407
+ "sc-7991 — injected @colixsystems/agents-client instance. " +
2408
+ "{ conversations(agentId) -> { create(), list(query), remove(conversationId), " +
2409
+ "messages(conversationId) -> { list(query), send({ content }) } } }. " +
2410
+ "Backs useAgent(). The workspace's agent owns the system prompt and the " +
2411
+ "server owns the transcript, so `send` puts ONLY `content` on the wire — " +
2412
+ "a prompt, a history or a model id cannot be smuggled through. " +
2413
+ "A conversation belongs to the CALLER (the app user, or the studio user " +
2414
+ "previewing on the canvas); another user's id is a 404, never a 403. " +
2415
+ "Absent on a host that brokers no agents (an unbound preview), where " +
2416
+ "useAgent() degrades to available: false rather than throwing.",
2417
+ required: false,
2418
+ fields: { conversations: "function" },
2419
+ },
2274
2420
  datastore: {
2275
2421
  description:
2276
2422
  "Injected @colixsystems/datastore-client instance. " +
@@ -4054,6 +4200,10 @@ const CONTRACT = deepFreeze({
4054
4200
  // and the inherited colours are hex-guarded on the way out as well as in.
4055
4201
  // Every colour still falls back to the sidebar's: an untouched workspace is
4056
4202
  // unchanged.
4203
+ // 1.116.0: additive (sc-7991) — `useAgent(agentRef)` + the `agents` host
4204
+ // client + the `agentRef` propertySchema type + the `ai.invoke:*` scope. A
4205
+ // widget can hold a conversation with one of the WORKSPACE's own AI agents.
4206
+ // Nothing existing changes shape.
4057
4207
  // 1.114.0: behaviour (sc-7965) — `resolveFooterTokens` / `resolveSidebarTokens`
4058
4208
  // now return a `borderWidth` CLAMPED to 1-8 instead of any finite number.
4059
4209
  // The bound lived only in the export (`coerceBorderWidth(...) ?? 1`), so a
@@ -4080,7 +4230,20 @@ const CONTRACT = deepFreeze({
4080
4230
  // where the rail's and the header's keep sc-7965's 1-8 clamp. A backfill
4081
4231
  // (`20270123000000_sc8033_footer_tokens_uncouple`) materialised what each
4082
4232
  // workspace was inheriting, so no shipped app moved.
4083
- version: "1.115.0",
4233
+ // 1.117.0: additive (sc-8058) -- the six SEMANTIC text roles.
4234
+ // `themeTokens.typography.roles` seeds display / title / subtitle / body /
4235
+ // caption / overline, and `themeTextRoles` + `themeTextRoleFields` publish
4236
+ // the tier names and the per-role token vocabulary (each field's type and
4237
+ // bounds) a TENANT may override -- the single source the Studio control,
4238
+ // the Mason `set_theme` coercion and the docs read, exactly as
4239
+ // `themeComponents` already is. Typography carried two faces and a size
4240
+ // scale and nothing else, so every widget drawing a "subtitle" invented its
4241
+ // own literal and no theme edit could move them together. Which FACE a role
4242
+ // takes is deliberately absent -- that is structural (`ROLE_FACES` in the
4243
+ // hosts' shared textStyle module), not a theme token. Additive: no export
4244
+ // changed signature, and a theme carrying no `roles` renders on the seeded
4245
+ // defaults exactly as before.
4246
+ version: "1.117.0",
4084
4247
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4085
4248
  hooks: HOOKS,
4086
4249
  primitives: PRIMITIVES,
@@ -4096,6 +4259,8 @@ const CONTRACT = deepFreeze({
4096
4259
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
4097
4260
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
4098
4261
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
4262
+ themeTextRoles: THEME_TEXT_ROLES,
4263
+ themeTextRoleFields: THEME_TEXT_ROLE_FIELDS,
4099
4264
  themeSpacingScale: THEME_SPACING_SCALE,
4100
4265
  themeMenuTypes: THEME_MENU_TYPES,
4101
4266
  themeWidgetStyles: THEME_WIDGET_STYLES,
package/dist/contract.js CHANGED
@@ -108,6 +108,58 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
108
108
  headingFontFamily:
109
109
  'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
110
110
  sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
111
+ // REQ-THEME-ROLE (sc-8058): the app's type scale as six SEMANTIC roles.
112
+ // Typography carried two faces and a size scale and nothing else, so a
113
+ // widget needing a "subtitle" had nothing to reach for and each invented
114
+ // its own literal — bold in Notifications, italic in User Management,
115
+ // unweighted in Files. A role names the TIER; the theme owns its look.
116
+ //
117
+ // Which FACE a role takes lives in `_shared/textStyle.js` ROLE_FACES —
118
+ // structural, not themeable, so it resolves without theme data. `colorToken` is
119
+ // the SURFACE token the role is painted with, resolved per render against
120
+ // the surface-aware theme (REQ-THEME-DARK / REQ-THEME-SURFACE) rather than
121
+ // frozen as a hex — a theme may override it with an explicit `color`.
122
+ roles: Object.freeze({
123
+ display: Object.freeze({
124
+ fontSize: 32,
125
+ fontWeight: "700",
126
+ lineHeight: 1.05,
127
+ letterSpacing: -0.5,
128
+ colorToken: "onSurface",
129
+ }),
130
+ title: Object.freeze({
131
+ fontSize: 24,
132
+ fontWeight: "700",
133
+ lineHeight: 1.15,
134
+ letterSpacing: -0.2,
135
+ colorToken: "onSurface",
136
+ }),
137
+ subtitle: Object.freeze({
138
+ fontSize: 16,
139
+ fontWeight: "600",
140
+ lineHeight: 1.3,
141
+ colorToken: "onSurface",
142
+ }),
143
+ body: Object.freeze({
144
+ fontSize: 16,
145
+ fontWeight: "400",
146
+ lineHeight: 1.5,
147
+ colorToken: "onSurface",
148
+ }),
149
+ caption: Object.freeze({
150
+ fontSize: 12,
151
+ fontWeight: "400",
152
+ lineHeight: 1.4,
153
+ colorToken: "onSurfaceMuted",
154
+ }),
155
+ overline: Object.freeze({
156
+ fontSize: 12,
157
+ fontWeight: "600",
158
+ lineHeight: 1.4,
159
+ letterSpacing: 1.5,
160
+ colorToken: "onSurfaceMuted",
161
+ }),
162
+ }),
111
163
  }),
112
164
  // REQ-THEME-15: the tenant's per-component style tokens, folded in by the
113
165
  // host so ONE channel carries them to the Player and the export. Empty by
@@ -172,6 +224,44 @@ const THEME_COMPONENT_TEXT_TRANSFORMS = Object.freeze([
172
224
  "capitalize",
173
225
  ]);
174
226
 
227
+ // REQ-THEME-ROLE (sc-8058) -- the THEME side of the six semantic text tiers, in
228
+ // the order the widgets' own TEXT_ROLES lists them. `themeTokens.typography.roles`
229
+ // carries the seeded values; this names what a TENANT may override, so the Studio
230
+ // control, the Mason `set_theme` coercion and the docs read ONE vocabulary --
231
+ // exactly as `themeComponents` already is for the per-component tokens.
232
+ const THEME_TEXT_ROLES = Object.freeze([
233
+ "display",
234
+ "title",
235
+ "subtitle",
236
+ "body",
237
+ "caption",
238
+ "overline",
239
+ ]);
240
+
241
+ // Mirrors the backend's SITE_HEADER_FONT_WEIGHTS so one theme speaks one weight
242
+ // vocabulary. Strings, because that is what both hosts' `fontWeight` takes.
243
+ const THEME_TEXT_ROLE_FONT_WEIGHTS = Object.freeze([
244
+ "100", "200", "300", "400", "500", "600", "700", "800", "900",
245
+ ]);
246
+
247
+ const THEME_TEXT_ROLE_FONT_STYLES = Object.freeze(["normal", "italic"]);
248
+
249
+ // The per-role token vocabulary, declared like a `themeComponents` scope's
250
+ // `tokens`: each field's value type and bounds stated ONCE, so the Studio
251
+ // control, the set_theme coercion and the host validator cannot disagree.
252
+ // `lineHeight` is a MULTIPLE of `fontSize` (REQ-THEME-13's grammar), so one
253
+ // value is right at every size; `color` overrides the role's surface-aware
254
+ // `colorToken`, which is why no role seeds a literal colour.
255
+ const THEME_TEXT_ROLE_FIELDS = Object.freeze({
256
+ fontSize: Object.freeze({ type: "size", min: 8, max: 96 }),
257
+ fontWeight: Object.freeze({ type: "enum", values: THEME_TEXT_ROLE_FONT_WEIGHTS }),
258
+ fontStyle: Object.freeze({ type: "enum", values: THEME_TEXT_ROLE_FONT_STYLES }),
259
+ lineHeight: Object.freeze({ type: "decimal", min: 0.8, max: 3, step: 0.05 }),
260
+ letterSpacing: Object.freeze({ type: "decimal", min: -2, max: 20, step: 0.1 }),
261
+ textTransform: Object.freeze({ type: "textTransform" }),
262
+ color: Object.freeze({ type: "color" }),
263
+ });
264
+
175
265
  // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
176
266
  // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
177
267
  // variant (a component fill projects through `<Gradient>`, which is linear-only
@@ -1172,6 +1262,47 @@ const HOOKS = [
1172
1262
  requiredContextSlice: ["datastore.interpret"],
1173
1263
  scopes: ["datastore.read:<table>"],
1174
1264
  },
1265
+ {
1266
+ name: "useAgent",
1267
+ signature: "useAgent(agentRef)",
1268
+ description:
1269
+ "sc-7991 — hold a conversation with one of the WORKSPACE's own AI " +
1270
+ "agents (an author-written name + system prompt). IMPERATIVE: call " +
1271
+ "send(text) from an event handler, never on mount — the first send " +
1272
+ "opens the conversation, so a widget nobody types into costs nothing. " +
1273
+ "`messages` is the running transcript, oldest first, each " +
1274
+ "{ id, role, content, created_at } with role 'user' | 'assistant'. " +
1275
+ "You CANNOT set the system prompt, replay an edited transcript, " +
1276
+ "or pick a model. No API returns the prompt — but that is a guarantee " +
1277
+ "about the WIRE, not secrecy: a model can be talked into repeating its " +
1278
+ "own prompt, so never put credentials or hidden data in one. " +
1279
+ "or pick a model: the server assembles every turn from the stored " +
1280
+ "prompt plus the stored history, and `content` is the only thing that " +
1281
+ "goes on the wire. `agentRef` is an agent id an author bound through an " +
1282
+ "`agentRef` property — when it is missing, or the host brokers no " +
1283
+ "agents client, `available` is false and send() rejects UNAVAILABLE; " +
1284
+ "render a visible unbound state, NEVER a blank box. reset() drops the " +
1285
+ "thread and clears the transcript (a 'new chat' affordance). " +
1286
+ "Every send spends the workspace's AI CREDITS and is rate-limited per " +
1287
+ "caller AND per workspace, so call it once per user action. " +
1288
+ "AgentError.code distinguishes the refusals that share a status: " +
1289
+ "AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
1290
+ "NOT clear on a retry — never offer 'try again' for it — where " +
1291
+ "RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
1292
+ "it off, so hide the affordance rather than retrying. NEVER surface a " +
1293
+ "raw error or the workspace's billing state to an app user. " +
1294
+ "Reads ctx.agents.",
1295
+ returnShape: {
1296
+ send: "(text) => Promise<{ id, role, content, created_at } | null>",
1297
+ reset: "() => void",
1298
+ messages: "Array<{ id, conversation_id, role, content, created_at }>",
1299
+ sending: "boolean",
1300
+ error: "AgentError | null",
1301
+ available: "boolean // false when no agent is bound, or the host brokers none",
1302
+ },
1303
+ requiredContextSlice: ["agents.conversations"],
1304
+ scopes: ["ai.invoke:*"],
1305
+ },
1175
1306
  {
1176
1307
  name: "useDatastoreMutation",
1177
1308
  signature: "useDatastoreMutation(tableId)",
@@ -2267,6 +2398,21 @@ const WIDGET_CONTEXT_SHAPE = {
2267
2398
  required: false,
2268
2399
  fields: { params: "object", set: "function" },
2269
2400
  },
2401
+ agents: {
2402
+ description:
2403
+ "sc-7991 — injected @colixsystems/agents-client instance. " +
2404
+ "{ conversations(agentId) -> { create(), list(query), remove(conversationId), " +
2405
+ "messages(conversationId) -> { list(query), send({ content }) } } }. " +
2406
+ "Backs useAgent(). The workspace's agent owns the system prompt and the " +
2407
+ "server owns the transcript, so `send` puts ONLY `content` on the wire — " +
2408
+ "a prompt, a history or a model id cannot be smuggled through. " +
2409
+ "A conversation belongs to the CALLER (the app user, or the studio user " +
2410
+ "previewing on the canvas); another user's id is a 404, never a 403. " +
2411
+ "Absent on a host that brokers no agents (an unbound preview), where " +
2412
+ "useAgent() degrades to available: false rather than throwing.",
2413
+ required: false,
2414
+ fields: { conversations: "function" },
2415
+ },
2270
2416
  datastore: {
2271
2417
  description:
2272
2418
  "Injected @colixsystems/datastore-client instance. " +
@@ -4050,6 +4196,10 @@ const CONTRACT = deepFreeze({
4050
4196
  // and the inherited colours are hex-guarded on the way out as well as in.
4051
4197
  // Every colour still falls back to the sidebar's: an untouched workspace is
4052
4198
  // unchanged.
4199
+ // 1.116.0: additive (sc-7991) — `useAgent(agentRef)` + the `agents` host
4200
+ // client + the `agentRef` propertySchema type + the `ai.invoke:*` scope. A
4201
+ // widget can hold a conversation with one of the WORKSPACE's own AI agents.
4202
+ // Nothing existing changes shape.
4053
4203
  // 1.114.0: behaviour (sc-7965) — `resolveFooterTokens` / `resolveSidebarTokens`
4054
4204
  // now return a `borderWidth` CLAMPED to 1-8 instead of any finite number.
4055
4205
  // The bound lived only in the export (`coerceBorderWidth(...) ?? 1`), so a
@@ -4076,7 +4226,20 @@ const CONTRACT = deepFreeze({
4076
4226
  // where the rail's and the header's keep sc-7965's 1-8 clamp. A backfill
4077
4227
  // (`20270123000000_sc8033_footer_tokens_uncouple`) materialised what each
4078
4228
  // workspace was inheriting, so no shipped app moved.
4079
- version: "1.115.0",
4229
+ // 1.117.0: additive (sc-8058) -- the six SEMANTIC text roles.
4230
+ // `themeTokens.typography.roles` seeds display / title / subtitle / body /
4231
+ // caption / overline, and `themeTextRoles` + `themeTextRoleFields` publish
4232
+ // the tier names and the per-role token vocabulary (each field's type and
4233
+ // bounds) a TENANT may override -- the single source the Studio control,
4234
+ // the Mason `set_theme` coercion and the docs read, exactly as
4235
+ // `themeComponents` already is. Typography carried two faces and a size
4236
+ // scale and nothing else, so every widget drawing a "subtitle" invented its
4237
+ // own literal and no theme edit could move them together. Which FACE a role
4238
+ // takes is deliberately absent -- that is structural (`ROLE_FACES` in the
4239
+ // hosts' shared textStyle module), not a theme token. Additive: no export
4240
+ // changed signature, and a theme carrying no `roles` renders on the seeded
4241
+ // defaults exactly as before.
4242
+ version: "1.117.0",
4080
4243
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4081
4244
  hooks: HOOKS,
4082
4245
  primitives: PRIMITIVES,
@@ -4092,6 +4255,8 @@ const CONTRACT = deepFreeze({
4092
4255
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
4093
4256
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
4094
4257
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
4258
+ themeTextRoles: THEME_TEXT_ROLES,
4259
+ themeTextRoleFields: THEME_TEXT_ROLE_FIELDS,
4095
4260
  themeSpacingScale: THEME_SPACING_SCALE,
4096
4261
  themeMenuTypes: THEME_MENU_TYPES,
4097
4262
  themeWidgetStyles: THEME_WIDGET_STYLES,
package/dist/hooks.js CHANGED
@@ -2694,6 +2694,196 @@ export function useInterpretDraft(tableId) {
2694
2694
  return { interpret, interpreting, error, result, available };
2695
2695
  }
2696
2696
 
2697
+ /**
2698
+ * sc-7991 — structured error thrown by `useAgent().send`. Carries a stable
2699
+ * `code` so widgets branch without parsing message strings; mirrors the shape
2700
+ * of `TranslateError` / `NotificationError`.
2701
+ *
2702
+ * `code` is one of:
2703
+ * - "UNAVAILABLE" — the host brokers no agents client, or no agent
2704
+ * is bound (the widget should render its unbound
2705
+ * state, not an error)
2706
+ * - "AGENT_NOT_FOUND" — 404 (the bound agent was deleted)
2707
+ * - "AGENT_DISABLED" — 409 (the author turned it off; it still exists)
2708
+ * - "CONVERSATION_NOT_FOUND"— 404 (a stale thread; `reset()` starts a new one)
2709
+ * - "INVALID_MESSAGE" — 400 (empty, or past the length cap)
2710
+ * - "AI_QUOTA_EXCEEDED" — 429 (the WORKSPACE is out of AI credit; this
2711
+ * does NOT clear by retrying — say so)
2712
+ * - "RATE_LIMITED" — 429 (too many turns — retry shortly)
2713
+ * - "AI_UNAVAILABLE" — 503 (no AI provider configured)
2714
+ * - "AI_GENERATION_FAILED" — 502 (the provider did not answer)
2715
+ * - "AUTH_REQUIRED" — 401 (no signed-in app user)
2716
+ * - "INTERNAL" — anything else (network, 5xx)
2717
+ */
2718
+ export class AgentError extends Error {
2719
+ constructor(code, message, opts) {
2720
+ super(message);
2721
+ this.name = "AgentError";
2722
+ this.code = code;
2723
+ if (opts && opts.cause) this.cause = opts.cause;
2724
+ }
2725
+ }
2726
+
2727
+ function toAgentError(err) {
2728
+ if (err instanceof AgentError) return err;
2729
+ const status =
2730
+ err && err.response && typeof err.response.status === "number"
2731
+ ? err.response.status
2732
+ : err && typeof err.status === "number"
2733
+ ? err.status
2734
+ : null;
2735
+ const bodyCode =
2736
+ (err && err.response && err.response.data && err.response.data.code) ||
2737
+ (err && typeof err.code === "string" ? err.code : null);
2738
+ const bodyMessage =
2739
+ (err && err.response && err.response.data && err.response.data.message) ||
2740
+ (err && err.message) ||
2741
+ "The agent could not answer";
2742
+ let code = "INTERNAL";
2743
+ if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
2744
+ else if (status === 401) code = "AUTH_REQUIRED";
2745
+ else if (status === 429) code = "RATE_LIMITED";
2746
+ else if (status === 400) code = "INVALID_MESSAGE";
2747
+ else if (status === 409) code = "AGENT_DISABLED";
2748
+ else if (status === 404) code = "AGENT_NOT_FOUND";
2749
+ else if (status === 502) code = "AI_GENERATION_FAILED";
2750
+ else if (status === 503) code = "AI_UNAVAILABLE";
2751
+ return new AgentError(code, bodyMessage, { cause: err });
2752
+ }
2753
+
2754
+ /**
2755
+ * sc-7991 — hold a conversation with a workspace-authored AI agent.
2756
+ * Returns `{ send, reset, messages, sending, error, available }`.
2757
+ *
2758
+ * send(text) → Promise<{ role, content }> the agent's reply
2759
+ *
2760
+ * The hook is IMPERATIVE — it NEVER fires on mount; the widget calls `send`
2761
+ * from an event handler. The first `send` opens the conversation, so an author
2762
+ * who places the widget and never types costs the workspace nothing.
2763
+ *
2764
+ * `messages` is the running transcript this widget has seen, oldest first,
2765
+ * each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
2766
+ * The SERVER owns the real transcript and the system prompt: the widget sends
2767
+ * one message and can neither read nor set the prompt.
2768
+ *
2769
+ * `agentRef` is the agent id an author bound through an `agentRef` property.
2770
+ * When it is absent, or the host brokers no agents client (an unbound canvas
2771
+ * preview), `available` is false and `send` rejects UNAVAILABLE rather than
2772
+ * throwing at render — the widget must render a visible unbound state, never a
2773
+ * blank box (CLAUDE.md §8).
2774
+ *
2775
+ * Routes through the injected `@colixsystems/agents-client` at `ctx.agents`,
2776
+ * so the Player and the Expo export resolve against the identical client.
2777
+ */
2778
+ export function useAgent(agentRef) {
2779
+ const ctx = useWidgetContextOrThrow("useAgent");
2780
+ const available =
2781
+ Boolean(agentRef) &&
2782
+ Boolean(ctx.agents) &&
2783
+ typeof ctx.agents.conversations === "function";
2784
+
2785
+ const clientRef = useRef(available ? ctx.agents : null);
2786
+ clientRef.current = available ? ctx.agents : null;
2787
+ const agentIdRef = useRef(agentRef);
2788
+ agentIdRef.current = agentRef;
2789
+ // The open thread, held as the in-flight PROMISE rather than the id.
2790
+ //
2791
+ // A ref alone only fixes the SEQUENTIAL case. `if (!id) { id = await create() }`
2792
+ // reads the guard before the await and writes it after, so two overlapping
2793
+ // sends — a double-tap, or a widget sending on each keystroke — both see null,
2794
+ // both open a conversation, and both spend a paid turn. Latching the promise
2795
+ // makes the second caller await the FIRST create instead of starting another.
2796
+ const conversationRef = useRef(null);
2797
+
2798
+ const [messages, setMessages] = useState([]);
2799
+ const [sending, setSending] = useState(false);
2800
+ const [error, setError] = useState(null);
2801
+
2802
+ // Set during render when the binding changes; consumed below. A setState in
2803
+ // the render body would be a cascading re-render, so the flag is applied to
2804
+ // the value this render returns instead.
2805
+ const pendingResetRef = useRef(false);
2806
+
2807
+ // A changed binding is a different agent, so neither the open thread nor the
2808
+ // transcript carries — leaving the messages would render agent A's
2809
+ // conversation underneath agent B.
2810
+ const boundRef = useRef(agentRef);
2811
+ if (boundRef.current !== agentRef) {
2812
+ boundRef.current = agentRef;
2813
+ conversationRef.current = null;
2814
+ pendingResetRef.current = true;
2815
+ }
2816
+
2817
+ const reset = useCallback(() => {
2818
+ conversationRef.current = null;
2819
+ setMessages([]);
2820
+ setError(null);
2821
+ }, []);
2822
+
2823
+ const send = useCallback(async (text) => {
2824
+ const client = clientRef.current;
2825
+ const agentId = agentIdRef.current;
2826
+ if (!client || !agentId) {
2827
+ throw new AgentError("UNAVAILABLE", "No agent is available for this host");
2828
+ }
2829
+ const content = typeof text === "string" ? text.trim() : "";
2830
+ if (!content) {
2831
+ throw new AgentError("INVALID_MESSAGE", "A message is required");
2832
+ }
2833
+
2834
+ setSending(true);
2835
+ setError(null);
2836
+ try {
2837
+ const thread = client.conversations(agentId);
2838
+ if (!conversationRef.current) {
2839
+ // Assigned BEFORE the first await, so a concurrent send joins this one.
2840
+ conversationRef.current = thread.create().then((created) => {
2841
+ const id = created && created.id ? created.id : null;
2842
+ if (!id) {
2843
+ throw new AgentError("INTERNAL", "The conversation could not be opened");
2844
+ }
2845
+ return id;
2846
+ });
2847
+ }
2848
+ let conversationId;
2849
+ try {
2850
+ conversationId = await conversationRef.current;
2851
+ } catch (err) {
2852
+ // A failed open must not wedge the widget on a rejected promise.
2853
+ conversationRef.current = null;
2854
+ throw err;
2855
+ }
2856
+ const turn = await thread.messages(conversationId).send({ content });
2857
+ const userMessage = turn && turn.user_message ? turn.user_message : null;
2858
+ const reply = turn && turn.reply ? turn.reply : null;
2859
+ setMessages((prior) =>
2860
+ prior.concat(userMessage ? [userMessage] : [], reply ? [reply] : []),
2861
+ );
2862
+ setSending(false);
2863
+ return reply;
2864
+ } catch (err) {
2865
+ const e = toAgentError(err);
2866
+ // A stale thread is recoverable on the next send rather than permanently
2867
+ // broken: drop it so the widget opens a fresh one.
2868
+ if (e.code === "CONVERSATION_NOT_FOUND") conversationRef.current = null;
2869
+ setError(e);
2870
+ setSending(false);
2871
+ throw e;
2872
+ }
2873
+ }, []);
2874
+
2875
+ // The binding changed this render: hand back the empty transcript now rather
2876
+ // than one frame of the previous agent's conversation.
2877
+ let visibleMessages = messages;
2878
+ if (pendingResetRef.current) {
2879
+ pendingResetRef.current = false;
2880
+ visibleMessages = [];
2881
+ if (messages.length > 0) setMessages([]);
2882
+ }
2883
+
2884
+ return { send, reset, messages: visibleMessages, sending, error, available };
2885
+ }
2886
+
2697
2887
  /**
2698
2888
  * Datastore mutation hook. Returns { create, update, delete }, each method
2699
2889
  * returning a Promise. Routes through the injected
package/dist/index.d.ts CHANGED
@@ -663,6 +663,8 @@ export interface WidgetContext<TProps = unknown> {
663
663
  notifications: NotificationsClient;
664
664
  /** Injected @colixsystems/identification-client; backs useIdentification. */
665
665
  identification: IdentificationClient;
666
+ /** sc-7991 — injected @colixsystems/agents-client backing useAgent(). */
667
+ agents?: { conversations(agentId: string): unknown };
666
668
  /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
667
669
  renderer: { renderNode(node: unknown): unknown };
668
670
  events: { emit(eventName: string, payload?: unknown): void };
@@ -1204,6 +1206,36 @@ export function useInterpretDraft(
1204
1206
  tableId: string | null | undefined,
1205
1207
  ): InterpretDraftApi;
1206
1208
 
1209
+ export interface AgentMessage {
1210
+ id: string;
1211
+ conversation_id: string;
1212
+ /** The system prompt is NOT part of the transcript — it lives on the agent. */
1213
+ role: "user" | "assistant";
1214
+ content: string;
1215
+ created_at: string;
1216
+ }
1217
+
1218
+ export interface AgentApi {
1219
+ /** Imperative — call from an event handler. The first send opens the thread. */
1220
+ send(text: string): Promise<AgentMessage | null>;
1221
+ /** Drop the open thread and clear the transcript this widget has seen. */
1222
+ reset(): void;
1223
+ /** Oldest first. The SERVER owns the real transcript. */
1224
+ messages: AgentMessage[];
1225
+ sending: boolean;
1226
+ error: AgentError | null;
1227
+ /** False when no agent is bound, or the host brokers no agents client. */
1228
+ available: boolean;
1229
+ }
1230
+
1231
+ /**
1232
+ * sc-7991 — hold a conversation with a workspace-authored AI agent.
1233
+ * IMPERATIVE: never fires on mount, so an unused widget costs nothing. The
1234
+ * widget sends ONE message; the system prompt and the real transcript stay
1235
+ * server-side and are never readable or settable from here.
1236
+ */
1237
+ export function useAgent(agentRef: string | null | undefined): AgentApi;
1238
+
1207
1239
  // REQ-RT-07 realtime subscription transport state.
1208
1240
  export type DatastoreSubscriptionStatus =
1209
1241
  | "connecting"
@@ -1926,10 +1958,29 @@ export class DirectoryError extends Error {
1926
1958
  }
1927
1959
 
1928
1960
  /**
1929
- * sc-3783 — error class thrown by `useTranslate().translate`. The `code` is a
1930
- * stable categorisation widgets can branch on. TRANSLATION_QUOTA_EXCEEDED and
1931
- * TRANSLATE_NOT_CONFIGURED will not clear on a retry — show the original text.
1961
+ * sc-7991 — structured error thrown by `useAgent().send`. Branch on `code`:
1962
+ * `AI_QUOTA_EXCEEDED` is the workspace's credit ceiling and will NOT clear on
1963
+ * a retry, unlike `RATE_LIMITED`; `AGENT_DISABLED` means the author switched
1964
+ * the agent off; `AI_AGENTS_DISABLED` means the feature is not released for
1965
+ * this workspace. Never show an app user a raw code or the billing state.
1932
1966
  */
1967
+ export class AgentError extends Error {
1968
+ code:
1969
+ | "UNAVAILABLE"
1970
+ | "AGENT_NOT_FOUND"
1971
+ | "AGENT_DISABLED"
1972
+ | "CONVERSATION_NOT_FOUND"
1973
+ | "INVALID_MESSAGE"
1974
+ | "AI_QUOTA_EXCEEDED"
1975
+ | "RATE_LIMITED"
1976
+ | "AI_UNAVAILABLE"
1977
+ | "AI_GENERATION_FAILED"
1978
+ | "AUTH_REQUIRED"
1979
+ | "INTERNAL"
1980
+ | (string & {});
1981
+ constructor(code: string, message: string, opts?: { cause?: unknown });
1982
+ }
1983
+
1933
1984
  export class TranslateError extends Error {
1934
1985
  code:
1935
1986
  | "UNSUPPORTED"
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  NotificationError,
29
29
  DirectoryError,
30
30
  PermissionError,
31
+ AgentError,
31
32
  useDatastoreQuery,
32
33
  useDatastoreRecord,
33
34
  useDatastoreSchema,
@@ -35,6 +36,7 @@ export {
35
36
  useStableQuery,
36
37
  useCanWrite,
37
38
  useInterpretDraft,
39
+ useAgent,
38
40
  useAsset,
39
41
  useAssetsByTag,
40
42
  useFilestoreFiles,
@@ -28,6 +28,7 @@ export {
28
28
  NotificationError,
29
29
  DirectoryError,
30
30
  PermissionError,
31
+ AgentError,
31
32
  useDatastoreQuery,
32
33
  useDatastoreRecord,
33
34
  useDatastoreSchema,
@@ -35,6 +36,7 @@ export {
35
36
  useStableQuery,
36
37
  useCanWrite,
37
38
  useInterpretDraft,
39
+ useAgent,
38
40
  useAsset,
39
41
  useAssetsByTag,
40
42
  useFilestoreFiles,
package/dist/linter.cjs CHANGED
@@ -565,6 +565,26 @@ function _scopeRules(source, manifest) {
565
565
  });
566
566
  }
567
567
  }
568
+
569
+ // sc-7991 — every send spends the workspace's AI credits, so the manifest has
570
+ // to declare the capability. This gate is ADVISORY, not an authorization
571
+ // boundary: unlike users/groups (minted into X-Widget-Scopes and checked
572
+ // server-side), nothing re-checks ai.invoke at call time, so a widget that
573
+ // evades this regex still reaches the endpoint. What actually bounds abuse is
574
+ // the app-user JWT, the per-actor and per-tenant limiters and the AI quota
575
+ // gate. The scope's real job is DISCLOSURE — it is what the marketplace
576
+ // install consent screen shows.
577
+ if (/\buseAgent\s*\(/.test(source)) {
578
+ const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
579
+ if (!invokes) {
580
+ findings.push({
581
+ rule: "scope-required-for-useAgent",
582
+ label: "useAgent() requires `ai.invoke:*` in manifest.requestedScopes",
583
+ line: 0,
584
+ snippet: "",
585
+ });
586
+ }
587
+ }
568
588
  const lines = source.split(/\r?\n/);
569
589
  let firedUsersWrite = false;
570
590
  let firedUsersDelete = false;
package/dist/linter.js CHANGED
@@ -528,6 +528,26 @@ function _scopeRules(source, manifest) {
528
528
  });
529
529
  }
530
530
  }
531
+
532
+ // sc-7991 — every send spends the workspace's AI credits, so the manifest has
533
+ // to declare the capability. This gate is ADVISORY, not an authorization
534
+ // boundary: unlike users/groups (minted into X-Widget-Scopes and checked
535
+ // server-side), nothing re-checks ai.invoke at call time, so a widget that
536
+ // evades this regex still reaches the endpoint. What actually bounds abuse is
537
+ // the app-user JWT, the per-actor and per-tenant limiters and the AI quota
538
+ // gate. The scope's real job is DISCLOSURE — it is what the marketplace
539
+ // install consent screen shows.
540
+ if (/\buseAgent\s*\(/.test(source)) {
541
+ const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
542
+ if (!invokes) {
543
+ findings.push({
544
+ rule: "scope-required-for-useAgent",
545
+ label: "useAgent() requires `ai.invoke:*` in manifest.requestedScopes",
546
+ line: 0,
547
+ snippet: "",
548
+ });
549
+ }
550
+ }
531
551
  const lines = source.split(/\r?\n/);
532
552
  let firedUsersWrite = false;
533
553
  let firedUsersDelete = false;
@@ -23,6 +23,11 @@ const VALID_TYPES = new Set([
23
23
  // value is the raw uuid string so the tenant-copy walker can remap it
24
24
  // through `_remapJson` without per-prop hooks.
25
25
  "groupRef",
26
+ // sc-7991: `agentRef` is a picker for one of the workspace's AI agents,
27
+ // emitting a bare TenantAiAgent UUID into the page JSON. A widget reads it
28
+ // and calls `useAgent(agentRef)`. Bare uuid string like `groupRef`/`pageRef`,
29
+ // so tenant-copy remaps it through the layout walk's merged id map.
30
+ "agentRef",
26
31
  // REQ-WDG-PAGEREF: `pageRef` is a page picker that emits a bare AppPage
27
32
  // UUID into the page JSON. Renders a page `<select>` in the Studio
28
33
  // Properties Panel; a widget reads it and navigates via
@@ -148,6 +153,7 @@ function coerceLeaf(def, value, path, errors) {
148
153
  case "recordBinding":
149
154
  case "groupRef":
150
155
  case "pageRef":
156
+ case "agentRef":
151
157
  case "richText":
152
158
  case "asset":
153
159
  case "folderRef":
@@ -75,7 +75,7 @@ function normaliseImageUrl(raw) {
75
75
 
76
76
  // A single tint laid over the image. Alpha lives in the 8-digit hex, so the
77
77
  // author controls opacity with the colour itself rather than a second field —
78
- // the same way `siteHeader.backgroundColor` already floats a header.
78
+ // the same way `siteHeader.backgroundColor` already tints a header.
79
79
  function normaliseOverlay(raw) {
80
80
  return hexOrNull(raw);
81
81
  }
@@ -67,7 +67,7 @@ function normaliseImageUrl(raw) {
67
67
 
68
68
  // A single tint laid over the image. Alpha lives in the 8-digit hex, so the
69
69
  // author controls opacity with the colour itself rather than a second field —
70
- // the same way `siteHeader.backgroundColor` already floats a header.
70
+ // the same way `siteHeader.backgroundColor` already tints a header.
71
71
  function normaliseOverlay(raw) {
72
72
  return hexOrNull(raw);
73
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.147.0",
3
+ "version": "0.149.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",