@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.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.
Files changed (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
@@ -1,1315 +1,1315 @@
1
- # @bridgeline-digital/hawk-app-bridge
2
-
3
- Build **Hawk Apps**: small tools that run inside the HawkSearch dashboard. Your app gets the
4
- signed-in user's context (client, engine) and the client's data through this package, with **no API
5
- keys in your code**.
6
-
7
- Guides, the full API reference and the CLI are at **[developers.hawksearchapps.com](https://developers.hawksearchapps.com)**
8
- (sign in with your Bridgeline account).
9
-
10
- - Any static front end works: React, Next.js (static export), Vue, plain HTML. Apps are static files on the apps CDN;
11
- there is no server of your own.
12
- - Pair it with the CLI, [`@bridgeline-digital/hawk-app-cli`](https://www.npmjs.com/package/@bridgeline-digital/hawk-app-cli),
13
- to create, run locally and deploy apps.
14
-
15
- ## Quick start
16
-
17
- ```bash
18
- npx @bridgeline-digital/hawk-app-cli@latest create my-tool --template react --install
19
- cd my-tool
20
- npx hawk-app sso # once per machine: sign in with your Bridgeline account
21
- npm run app:dev # the app in a local stand-in for HawkSearch, with a client's data
22
- ```
23
-
24
- The first time, `app:dev` asks for part of a client's name and saves your pick in `.env.local`
25
- (`npm run app:dev -- --client <id>` picks another).
26
-
27
- The template is already wired to this package. You can also open it inside HawkSearch in
28
- [dev mode](#local-development). When it's ready:
29
-
30
- ```bash
31
- npm run app:deploy # build and upload a new version, with hawk-app.json (no folder needed)
32
- npx hawk-app open # the app in HawkSearch: review it and submit it for approval
33
- ```
34
-
35
- New versions are drafts that only Bridgeline staff see; an approver releases them from the Apps page in HawkSearch (see
36
- [`hawk-app.json`](#hawk-appjson)).
37
-
38
- ## Connect to the host
39
-
40
- ```ts
41
- import { createApp } from '@bridgeline-digital/hawk-app-bridge/client';
42
-
43
- const app = await createApp({ autoResize: true });
44
-
45
- render(app.context);
46
- app.onContextChange((context) => render(context)); // the user switched client, engine or dates
47
- ```
48
-
49
- **Where the app is visible.** A frame that grows with its content (auto-resize) can be much taller than the screen, so
50
- a dialog centered in the frame may open off screen. The host reports the visible part of the frame: `app.viewport`
51
- (`{ top, height }` in px from the frame's top), `app.onViewportChange(listener)`, and the CSS variables
52
- `--hawk-visible-top` and `--hawk-visible-height`. The UI kit's theme (`@bridgeline-digital/hawk-app-ui`, in the next-mui template) uses them to place MUI dialogs and
53
- snackbars; other apps can do the same in CSS, e.g.
54
- `.modal { position: absolute; top: calc(var(--hawk-visible-top, 0px) + 40px); }`.
55
-
56
- `createApp` resolves once the host has answered with the context. It rejects with code `not_embedded` when the page
57
- is opened directly instead of inside a host: show a friendly message (the templates do).
58
-
59
- You don't configure which hosts may embed your app: the platform does. The apps CDN only lets the HawkSearch dashboard and Spark frame
60
- an app, and the bridge only talks to the window that embedded it.
61
-
62
- | Option | Default | |
63
- |---|---|---|
64
- | `autoResize` | `false` | Keep the frame as tall as your content. Leave it off for full-height layouts. |
65
- | `applyTheme` | `true` | Apply the host theme as `--hawk-*` CSS variables. |
66
- | `timeoutMs` | `10000` | How long to wait for the host to answer a request. |
67
-
68
- ## Context
69
-
70
- `app.context` is always current; `onContextChange` tells you when it changes.
71
-
72
- | Field | Example | |
73
- |---|---|---|
74
- | `tenant.clientId` | `"e3141"` | The client the user is working in. All data calls are scoped to it automatically. |
75
- | `tenant.analyticsProvisioned` | `false` | `false` when the client has no analytics data set up: show a message instead of calling `app.data` or `app.analytics` (they'd fail with `data_not_available` / `analytics_not_available`). Missing when the host doesn't say. |
76
- | `engine` | `{ id: "3141", name: "Outdoor Demo" }` | The engine selected in the host, if any. |
77
- | `site` | `{ id, name }` | The site, if the host has one selected. |
78
- | `dateRange` | `{ from: "2026-09-01", to: "2026-09-30", timeZone: "UTC" }` | The host's date picker. Analytics calls use it by default. |
79
- | `user` | `{ id, email, roles }` | The signed-in user. |
80
- | `locale` | `"en-US"` | Format numbers and dates with it. |
81
- | `theme` | `{ mode: "light", tokens }` | Also applied as CSS variables (see [Look and feel](#look-and-feel)). |
82
- | `features` | `{ assistant: true }` | What the host offers the app, e.g. `features.assistant`: the host's Hawk Assistant can be opened (see [Ask the host's assistant](#ask-the-hosts-assistant-assistant)). |
83
- | `view` | `{ name: "preview", params, embedded: true, maxHeight }` | The view showing, or null for the full app (see [Views](#views-an-app-inside-other-pages)). |
84
- | `app.slug` | `"my-tool"` | Your app. |
85
-
86
- ## Views: an app inside other pages
87
-
88
- Besides its own page, an app can show **views**: pages of the same build that hosts show inside other pages. Declare
89
- them in `hawk-app.json`:
90
-
91
- ```json
92
- "views": {
93
- "preview": { "path": "views/preview", "title": "Price position", "height": 220, "cacheMinutes": 60 },
94
- "product": { "path": "views/product", "title": "This product", "embeddable": true, "params": ["sku"] }
95
- }
96
- ```
97
-
98
- - **`preview`** is shown on the Apps page under the app's description.
99
- - **Embeddable views** can be placed by Bridgeline users in other pages (the report builder, later Spark pages).
100
- - **Each view is a page at `<entry>/<path>/`,** e.g. a Next.js route `src/app/views/preview/page.tsx`, with the same
101
- version, scopes and token as the app.
102
- - **`app.context.view`** says which view is showing and what the page passed in:
103
- `{ name, params: { sku: 'A-1' }, embedded: true, maxHeight }`. It's null for the full app.
104
- - **Keep views small and fast.** A `preview` shares the Apps page with every other app, so read the app's stored
105
- results rather than run reports or queries.
106
- - **`cacheMinutes` caches a view's reads** (0–1440; a `preview` caches 60 minutes unless it says otherwise, other views not at all): the platform keeps the answers to the view's data
107
- queries, analytics reports, `dashboard.get`, search and recommendations for that long, per client and engine, and
108
- answers the same call again from them. Set `"cacheMinutes": 0` on a preview that must always be fresh. The full app page is never cached,
109
- and a new version starts fresh. A cached `data.query` result has `cachedAt` (when it was stored). Pass
110
- `{ cache: 'reload' }` to ask the source again (and store the new answer), or `{ cache: 'no-store' }` for a call
111
- that must not be cached, e.g. an analytics report that records something. Engineering can shorten or stop it per
112
- app in the Hawk Analytics portal.
113
- - **`views` is a setting:** on a released app, changes wait for approval.
114
- - **A view's page is transparent:** the bridge marks it `data-hawk-view="<name>"` on `<html>` and clears the page
115
- background, so the host's shows through. To paint one, set it on the view's content, or with
116
- `html[data-hawk-view] body { … }`.
117
- - **A view fits its content:** it starts at its declared `height`, then auto-resize (on by default in a view) sizes it
118
- to the content, within the container's `min-height` and `max-height`. Don't set `html, body { height: 100% }` in a
119
- view, or it can't shrink.
120
- - **In a view**, toasts and title changes are ignored (the page isn't the app's), the height grows with
121
- `app.resize()` up to `maxHeight`, and `app.navigate({ intent: 'app', slug })` opens the full app.
122
- - **The trial view** of a store app (`"trial": true`, one at most, no params) runs on its store card for clients that
123
- don't have the app, on their own data. There `app.context.trial` is `true` and the token has only the app's
124
- read-only scopes (`reports:read`, `hawksearch:read`, `hawksearch:search`, `hawksearch:recommendations`,
125
- `data:read`): hide or disable what writes, sends, stores or asks the assistant, and say it's a trial.
126
- `navigate({ intent: 'app' })` opens the host's request dialog.
127
-
128
- ### Hosts: `<hawk-app-container>`
129
-
130
- Configure once per page, then place containers anywhere:
131
-
132
- ```ts
133
- import { configureHawkAppContainers, defineHawkAppContainer } from '@bridgeline-digital/hawk-app-bridge/host';
134
-
135
- defineHawkAppContainer();
136
- configureHawkAppContainers({
137
- // From the registry: the app's entry URL plus the view's path; null hides the view. { trial }: the store listing's trial view.
138
- resolveView: async (app, view, options) => ({ src, title, height, appName, description, params }),
139
- // The app's context now and on every change; return a function that stops the updates.
140
- watchContext: (app, listener) => subscribe(app, listener),
141
- // { trial }: a trial token (POST /token/exchange with trial: true).
142
- getToken: (app, { forceRefresh, trial }) => exchangeToken(app, forceRefresh, trial),
143
- // { trial }: the client doesn't have the app: offer a request instead.
144
- openApp: (app, params, options) => (options?.trial ? offerRequest(app) : router.open(app, params)),
145
- });
146
- ```
147
-
148
- ```html
149
- <hawk-app-container app="price-watch" view="preview" max-height="320" fallback="Compare your prices."></hawk-app-container>
150
- ```
151
-
152
- - **Attributes:** `app`, `view` (default `preview`), `params` (a JSON object; only names the view declares are passed),
153
- `full-app` (the entire app instead of a view: `resolveView(app, null)`; its context has no `view`),
154
- `min-height` (default 0), `max-height` (default 1200), `fallback` (text when the view can't load and the app has no description),
155
- `trial` (a store app's trial view: the host's `resolveView`, `getToken` and `openApp` get `{ trial: true }`, and
156
- the context `trial: true`).
157
- - **Loading:** when scrolled near, at most `maxConcurrent` (default 3) at a time; a placeholder of the view's height
158
- until it connects; the fallback after `loadTimeoutMs` (default 20 s).
159
- - **Styling:** the `state` attribute (`idle`, `loading`, `ready`, `fallback`) and a `hawk-view-state` event; the
160
- placeholder uses `--hawk-app-container-radius` and `--hawk-app-container-placeholder`.
161
-
162
- ### Hosts: `<hawk-app-slot>`
163
-
164
- A place in a host page where Bridgeline users put an app view, saved on the platform per engine or for every client:
165
-
166
- ```ts
167
- import { configureHawkAppSlots, defineHawkAppSlot } from '@bridgeline-digital/hawk-app-bridge/host';
168
-
169
- defineHawkAppSlot(); // also defines <hawk-app-container>: configure it as above
170
- configureHawkAppSlots({
171
- canEdit: isBridgelineUser,
172
- getSlots: (ids) => backend.get(`/slots?ids=${ids}`), // the platform's GET /slots, through the host backend
173
- listChoices: async () => appsWithPlaceableViews, // [{ app, name, views: [{ name, title, params }] }]
174
- saveSlot: (slot, change) => backend.put(`/slots/${slot}`, change), // { scope, app, view, params, minHeight, maxHeight }
175
- removeSlot: (slot, scope) => backend.delete(`/slots/${slot}?scope=${scope}`),
176
- appHref: (app) => `/apps/${app}`, // the app's page: the icon link's href
177
- openApp: (app) => router.open(app), // a plain click on the icon link
178
- });
179
- ```
180
-
181
- ```html
182
- <hawk-app-slot slot-id="dashboard.top"></hawk-app-slot>
183
- ```
184
-
185
- - **Clients** see the placement in a `<hawk-app-container>` (light DOM: style it with `hawk-app-slot > hawk-app-container`);
186
- an empty slot is `hidden`.
187
- - **Editors** (`canEdit`) get a pencil button (a "Place an app" placeholder when empty) opening a form: app, view (or
188
- *Entire app*: the app's own page, `<hawk-app-container full-app>`), the view's params, min and max height, *Show a
189
- link to open the app* (an icon in the slot's corner; not for the entire app), and *This engine only* or *All
190
- clients*. It fires `hawk-slot-change` after a save
191
- or remove.
192
- - **Slots on a page load in one call** (`getSlots` gets every id requested in the same task).
193
- - **Texts:** `labels` (defaults `DEFAULT_SLOT_LABELS`). Theme: `--hawk-slot-accent`, `--hawk-slot-text`,
194
- `--hawk-slot-muted`, `--hawk-slot-border`, `--hawk-slot-surface`, `--hawk-slot-font`, `--hawk-slot-radius`.
195
- - **The host backend adds the client, engine and editor** from its session: never from the browser.
196
-
197
- ### Hosts: `<hawk-app-icon>`
198
-
199
- An app's icon, from the registry's `iconUrl` and `iconKind` (registered by `defineHawkAppIcon()`, and by
200
- `defineHawkAppFrame()`, `defineHawkAppContainer()` and `defineHawkAppSlot()`):
201
-
202
- ```html
203
- <hawk-app-icon src="https://apps.example.com/price-watch/v3/icon.svg" kind="svg"></hawk-app-icon>
204
- ```
205
-
206
- - **Attributes:** `src`, `kind` (`svg` or `png`), `label` (the accessible name; without it the icon is decorative).
207
- - **`svg` is drawn in the host's color:** only its shape counts, filled with `currentColor` (a CSS mask), like the
208
- host's own icons in light and dark themes. **`png` is shown as is.**
209
- - **No `src`, or one that fails to load:** a built-in default app icon, in `currentColor`.
210
- - **Size and color:** it fills its own box, 24 × 24 px by default: set `width`, `height` and `color` on it. The
211
- `state` attribute (`svg`, `png`, `default`) says what it shows.
212
-
213
- ## Data
214
-
215
- Every call goes through the Hawk Apps Platform with a short-lived app token. The platform adds the credentials
216
- server-side and takes the client from the signed token, so an app can only ever read the current client's data. Ask
217
- for the scopes you need in `hawk-app.json`.
218
-
219
- ### Analytics reports: `reports:read`
220
-
221
- ```ts
222
- const top = await app.analytics.report('queries', { limit: 20 });
223
- // { columns: [{ name, type }], data: [...rows], totalRecords, page, hasMore }
224
-
225
- const lastWeek = await app.analytics.report('searches', { startDate: '2026-09-24', endDate: '2026-09-30' });
226
- ```
227
-
228
- Dates default to the host's date range. Request options: `startDate`, `endDate`, `limit` (default 100), `page`,
229
- `filters` and `having` (`{ member, operator, values }`), as in the
230
- [HawkSearch Analytics API](https://developers.analytics.hawksearch.com/), which documents each report's columns.
231
- Data refreshes nightly. A third argument takes `{ signal?, cache? }`; in a caching view, pass `{ cache: 'no-store' }`
232
- for a report that records something (see [Views](#views-an-app-inside-other-pages)).
233
-
234
- **Typed reports.** The bridge knows every report in the analytics API's spec: `report('searches')` returns typed rows,
235
- with numbers and booleans converted from the strings the API sends (`row.search_count` is a number), and `filters`
236
- and `having` take only that report's fields. A renamed or removed column fails your build once you update the bridge,
237
- instead of showing blanks. `AnalyticsReportRow<'searches'>` is a row's type. The writes are typed too:
238
- `report('ai-advisor-feedback', { recommendationId, engineId, feedback })` returns `{ feedback }`.
239
-
240
- ```ts
241
- const { data } = await app.analytics.report('assistant-activity', {
242
- filters: [{ member: 'model_id', operator: 'equals', values: ['…'] }], // only this report's fields
243
- });
244
- const tokens = data.reduce((sum, row) => sum + row.total_tokens, 0); // numbers, not "123"
245
- ```
246
-
247
- Every report the analytics API has can be called, by its path without the slash (`assistant-prompts`); new ones need
248
- no bridge or platform change: a report newer than your bridge returns untyped rows (`report<MyRow>('new-report')`),
249
- with values as the API sends them. Passing your own row type to a known report (`report<MyRow>('searches')`) opts out
250
- of its types, though its values are still converted. `report('', { query: '…' })` is the API's ad-hoc / natural-language query. For the list,
251
- each report's columns and its `filters`/`having` fields, see the
252
- [developer portal](https://developers.analytics.hawksearch.com/) (the dev platform uses the dev analytics API:
253
- [its spec](https://developers.analytics.hawksearch.com/dev/openapi.yaml)), or ask your assistant: the `hawk-apps` MCP
254
- server's `analytics_reports` tool reads that spec.
255
-
256
- ### HawkSearch dashboard API: `hawksearch:read`
257
-
258
- ```ts
259
- const synonyms = await app.dashboard.get('Synonym');
260
- const fields = await app.dashboard.get('Field');
261
- // Query parameters: app.dashboard.get('LandingPage', { query: { ... } })
262
- ```
263
-
264
- Any resource of the [HawkSearch Dashboard API](https://developerdocs.hawksearch.com/reference/getting-started-with-your-api):
265
- `Synonym`, `LandingPage`, `Field`, …. Leave the API version out; the platform picks it. Responses are HawkSearch's
266
- body as is. To change data, see [Writing to HawkSearch](#writing-to-hawksearch-hawksearchwrite). For reporting, use
267
- analytics. Your assistant can look up any operation's fields, its bridge call and scope with the `hawk-apps` MCP
268
- server's `hawksearch_api` tool.
269
-
270
- ### Writing to HawkSearch: `hawksearch:write`
271
-
272
- ```ts
273
- // Create a synonym
274
- const synonym = await app.dashboard.post('Synonym', {
275
- SearchTerm: 'sneakers',
276
- SynonymGroup: 'trainers,running shoes',
277
- MultiWay: true,
278
- });
279
-
280
- // Update a redirect
281
- await app.dashboard.put('Redirect/42', { ...redirect, NewUrl: '/sale' });
282
-
283
- // Delete a banner
284
- await app.dashboard.delete('Banner/7');
285
- ```
286
-
287
- `app.dashboard.post(path, body?)`, `put(path, body?)` and `delete(path)` (also on `ctx.platform.dashboard` in
288
- [functions](#functions-functionsinvoke)) send the body as JSON to the
289
- [HawkSearch Dashboard API](https://developerdocs.hawksearch.com/reference/getting-started-with-your-api) and resolve
290
- to HawkSearch's response body (`undefined` when it's empty). Paths work as for `get`: no API version (`Synonym`,
291
- `Synonym/123`, `LandingPage/bulk`); each segment is URL-encoded for you.
292
-
293
- - **Scope**: add `"hawksearch:write"` to `scopes` in `hawk-app.json` (reviewed like any scope). Reading stays under
294
- `hawksearch:read`; ask for both if you read what you write.
295
- - **Allowed resources**: `Banner`, `BoostBury`, `Campaign`, `Visibility`, `VisitorTarget`, `Redirect`, `Synonym`,
296
- `SpellCheck`, `PaginationSet`, `SortingSet`, `RecommendationVisibility`, `LandingPageLayout`, `Facet` and
297
- `LandingPage` (and their sub-paths). Anything else, e.g. `Field`, fails with 403 `path_not_allowed`; so do bulk
298
- landing-page deletes.
299
- - **Limits**: bodies up to 5 MB, bulk requests up to 1000 items, and 120 writes per minute per app and client.
300
- - **Site keys only**: writes go through a site's own HawkSearch API key. When the client is connected with a group
301
- key, writes fail with 403 `hawksearch_write_needs_site_key` (reads still work).
302
- - **Audited**: every write is audit-logged by the platform (which app, for which client, by which user).
303
- - **Not retried**: writes aren't idempotent, so the bridge sends each one once. A 429 `too_many_requests` is thrown
304
- with `error.retryAfterMs`; wait at least that long before you try again, and only if repeating the write is safe.
305
-
306
- | Code | Status | Meaning |
307
- |---|---|---|
308
- | `scope_missing` | 403 | The app doesn't have the `hawksearch:write` scope. |
309
- | `path_not_allowed` | 403 | The resource isn't one of the allowed ones above (or it's a bulk landing-page delete). |
310
- | `hawksearch_write_needs_site_key` | 403 | The client is connected with a group key; writes need a site key. |
311
- | `request_too_large` | 413 | The body is over 5 MB. |
312
- | `too_many_items` | 400 | A bulk request has more than 1000 items. |
313
- | `too_many_requests` | 429 | Over 120 writes a minute for this app and client; `retryAfterMs` says when to try again. Not retried. |
314
- | `invalid_path` | 400 | The path is malformed (the bridge also rejects empty paths and `.`/`..` segments with a `TypeError`). |
315
- | `version_not_allowed` | 400 | You put an API version in the path: use `Synonym`, not `v11/Synonym`. |
316
- | `hawksearch_unavailable` | 502 | HawkSearch couldn't be reached: the write may or may not have happened, so check before you repeat it. |
317
- | `hawksearch_credentials_rejected` | 502 | HawkSearch rejected the client's API key: tell Engineering. |
318
-
319
- HawkSearch's own validation errors (e.g. 400 for a missing required field, 404 for an unknown id) come back as
320
- `HawkApiError` with HawkSearch's status and its response in `error.body`.
321
-
322
- ### Custom data (queries): `data:read`
323
-
324
- When the reports don't have what you need, ship your own SQL with the app. Each query is two files in `queries/`,
325
- next to `hawk-app.json` (not in the build folder); `hawk-app deploy` checks them and deploys them with the version,
326
- and Bridgeline staff see them when they review it. The platform runs them (Amazon Athena) against the **current
327
- client's** analytics tables only: the client comes from the app token, never from the app.
328
-
329
- ```sql
330
- -- queries/top-searches.sql: one SELECT (or WITH … SELECT), plain table names, ? for each parameter in order
331
- SELECT search_date, sum(search_count) AS searches
332
- FROM dailysearches
333
- WHERE search_date BETWEEN ? AND ?
334
- AND {{partitions(from, to)}}
335
- GROUP BY 1
336
- ORDER BY 1
337
- ```
338
-
339
- ```json
340
- {
341
- "params": [
342
- { "name": "from", "type": "date", "required": true },
343
- { "name": "to", "type": "date", "required": true }
344
- ]
345
- }
346
- ```
347
-
348
- (`queries/top-searches.json`. `from` and `to` bind the two `?`s, in order.)
349
-
350
- ```ts
351
- // `date` params are "YYYY-MM-DD"; the host's date range is full ISO timestamps, so cut them to the day.
352
- const day = (iso: string) => iso.slice(0, 10);
353
- const { from, to } = app.context.dateRange!;
354
-
355
- const { columns, rows, bytesScanned, cached } = await app.data.query<{ search_date: string; searches: number }>('top-searches', {
356
- from: day(from),
357
- to: day(to),
358
- });
359
- // rows: [{ search_date: '2026-09-01', searches: 1204 }, ...]
360
- ```
361
-
362
- `app.data.query(name, params?, { signal?, timeoutMs?, cache? })` resolves to `{ columns: [{ name, type }], rows, rowCount,
363
- bytesScanned, cached, cachedAt }` (`cachedAt`: when the platform's view cache stored the rows, else null; `cache`:
364
- `'reload'` or `'no-store'`, see [Views](#views-an-app-inside-other-pages)). Rows are objects keyed by column name, typed: numbers for integer/decimal/double columns,
365
- booleans, ISO strings for dates and timestamps, `null` for nulls. Long queries are polled for you (1 s, 2 s, 4 s, then
366
- every 5 s) until they finish or `timeoutMs` (default 120 s) passes; pass an `AbortSignal` to stop waiting (the
367
- promise rejects with an `AbortError`). When too many queries are running (429 `too_many_queries`) the call waits and
368
- tries again (1 s, 2 s, 4 s, then every 5 s, never sooner than the platform's `Retry-After`), until `timeoutMs`; only
369
- then does it reject with the 429. `cached` is `true` when the result wasn't computed for this call: Athena reused a
370
- recent result of the same query and parameters, or an identical request made moments earlier was already running it.
371
-
372
- **Parameters** are declared in `<name>.json`, in the order of the `?`s in the SQL, and are always sent as query
373
- parameters (never pasted into the SQL):
374
-
375
- | Type | Value |
376
- |---|---|
377
- | `string` | up to 1000 characters |
378
- | `int`, `decimal` | a number; `min` / `max` optional |
379
- | `date` | `"YYYY-MM-DD"` |
380
- | `timestamp` | ISO 8601, e.g. `"2026-09-01T00:00:00Z"` |
381
- | `boolean` | `true` / `false` |
382
- | `string[]` | up to 100 strings (`maxItems` to lower it); counts as **one** `?`, written `IN ?` (it becomes `IN (?, ?, …)`) |
383
-
384
- Each param has a `name` and `type`, and optionally `required`, `default`, `min`/`max` (numbers) and `maxItems`
385
- (`string[]`). The number of `?`s must equal the number of params.
386
-
387
- **`{{partitions(from, to)}}`** limits the scan to the months between two `date` params (both must be declared
388
- `date`; they still bind their own `?`s). Use it on every query with a date range: most tables are partitioned by
389
- year/month, so without it a query reads the client's whole history. Add the table alias as a third argument when the
390
- query joins tables: `{{partitions(from, to, d)}}`. On a table that isn't partitioned it becomes `TRUE`.
391
-
392
- **Tables:** `apiusage`, `dailysearches`, `dailyusers`, `dailyrequests`, `geodistribution`, `redirecttracking`,
393
- `productimpressions`, `searchfunnel`, `pageloads`, `analyticsapilogs`, `queries`, `clickpositions`, `facets`,
394
- `sortingtracking`, `paginationtracking`, `poorresults`, `dailyspelling`, `salesbydate`, `salesbykeyword`,
395
- `salesbyproduct`, `recommendations`, `campaigns`, `banners`, `autocompleteclicks`, `autocompletetrendingcategories`,
396
- `autocompletetrendingitems`, `productratings`, `devicebrowser`, `landingpages`, `gacore`, `catalog`. Any other table
397
- fails with `TABLE_NOT_FOUND`.
398
-
399
- **Sensitive tables** hold visitor-level data or conversations: `summary`, `agentprompts`, `agentexchanges`,
400
- `agenttoolcalls`, `aisummary`, `facetselections`, `visitortargetselections`. An app can query them only after
401
- Engineering turns on **Allow sensitive data** for it (in the analytics portal; it applies to every client the app runs
402
- for). Until then they also fail with `TABLE_NOT_FOUND`. Ask Engineering when your app needs one, and select only the
403
- columns you need (counts rather than raw text where you can). `npx hawk-app data tables` and `npx hawk-app data describe <table>` show the columns, and
404
- `npx hawk-app data run queries/top-searches.sql --client <id> --param from=2026-09-01 --param to=2026-09-30` tries a
405
- query on dev before you deploy it.
406
-
407
- **Limits:** one statement per file, `SELECT`/`WITH` only, 64 KB per query, 50 queries per app, 10,000 rows / 5 MB per
408
- result, and a per-query scan limit (10 GB by default). Results of the same query with the same parameters are reused
409
- for up to 15 minutes (`cached: true`). Data refreshes nightly.
410
-
411
- **Errors** (`HawkApiError.code`):
412
-
413
- | Code | Status | Meaning |
414
- |---|---|---|
415
- | `query_not_found` | 404 | No `queries/<name>.sql` in the version the user has open. |
416
- | `invalid_params` | 400 | A parameter is missing or has the wrong type; `JSON.parse(error.body).errors` lists them. |
417
- | `query_failed` | 400 | Athena rejected or failed the query (the message says why, e.g. an unknown column). |
418
- | `scan_limit_exceeded` | 400 | The query reads too much data: narrow the date range or use `{{partitions(…)}}`. |
419
- | `result_too_large` | 413 | More than 10,000 rows or 5 MB: aggregate, filter or add `LIMIT`. |
420
- | `too_many_queries` | 429 | Too many queries running (at most 2 per app and client at a time): `app.data.query` already retried until `timeoutMs`. |
421
- | `query_timeout` | 408 | Still running after `timeoutMs` (raised by the bridge). |
422
- | `data_not_available` | 404 | Analytics data isn't set up for this client (when `context.tenant.analyticsProvisioned` is `false` it answers at once, without running the query). |
423
- | `data_unavailable` | 502 | Something is wrong on our side: retry later. |
424
-
425
- **Writing good queries**
426
- - Filter by date and add `{{partitions(from, to)}}`: it's what keeps queries fast and cheap.
427
- - Aggregate in SQL (`GROUP BY`, `sum`, `count`) and return only what the screen shows; add `LIMIT` to lists.
428
- - Name the columns you need instead of `SELECT *`: Athena reads only those columns.
429
- - Column names come back exactly as written; give computed columns an alias (`sum(x) AS searches`).
430
- - Run independent queries in parallel (`Promise.all`), but no more than two at a time per client: more just wait
431
- their turn.
432
- - Show something while waiting: a first run can take a few seconds; repeats within 15 minutes are instant.
433
-
434
- ### Who am I
435
-
436
- ```ts
437
- const me = await app.api.get('me'); // { app, clientId, accountId, userId, scopes, internal }
438
- // internal: true for Bridgeline staff (from the signed token), e.g. to show a staff-only tab
439
- ```
440
-
441
- ## Functions: `functions:invoke`
442
-
443
- Server-side code that ships with your app: call a third-party API with a key the browser must never see, combine
444
- several platform calls, or reshape data before it reaches the app. Each function is a file in `functions/`, deployed
445
- with every version as an AWS Lambda function (Node 22) and called from the app with `app.functions.call`.
446
-
447
- **Engineering must enable functions for your app** (they reach the internet and can hold secrets). Until then calls
448
- fail with `functions_not_enabled`. A version whose functions changed (added, removed, new code or settings) **always
449
- needs review**, even on an auto-approve app.
450
-
451
- ```
452
- my-app/
453
- hawk-app.json "scopes": ["functions:invoke", …],
454
- "functions": { "sync-feed": { "timeoutSeconds": 20, "memoryMb": 512, "secrets": ["FEED_API_KEY"] } }
455
- functions/
456
- sync-feed.ts one entry file per function (.ts, .mts, .js or .mjs), named after it, exporting `handler`
457
- lib/feed.ts shared code goes in subfolders (or import from src/ and node_modules)
458
- ```
459
-
460
- ```ts
461
- // functions/sync-feed.ts
462
- import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
463
-
464
- export const handler: HawkFunction<{ since: string }, { items: unknown[] }> = async (input, ctx) => {
465
- const response = await fetch(`https://feeds.example.com/items?since=${input.since}`, {
466
- headers: { Authorization: `Bearer ${ctx.secrets.FEED_API_KEY}` },
467
- });
468
- if (!response.ok) throw new Error(`Feed answered ${response.status}`);
469
- const top = await ctx.platform.analytics.report('searches', { startDate: input.since, limit: 10 });
470
- return { items: [...(await response.json()), ...top.data] };
471
- };
472
- ```
473
-
474
- ```ts
475
- // In the app
476
- const { items } = await app.functions.call<{ items: unknown[] }>('sync-feed', { since: '2026-09-01' });
477
- ```
478
-
479
- `app.functions.call(name, input?, { signal?, timeoutMs? })` posts `input` (any JSON, up to 256 KB) and resolves to
480
- what `handler` returned (up to 6 MB). It waits up to `timeoutMs` (default 30 s) and retries while the platform answers
481
- 429 (too many calls running), like data queries.
482
-
483
- **`ctx`** (`HawkFunctionContext`)
484
-
485
- | | |
486
- |---|---|
487
- | `ctx.client.id` | The HawkSearch client the app is running for. |
488
- | `ctx.user` | `{ id, email, roles }` of the caller; `ctx.internal` is true for Bridgeline staff. |
489
- | `ctx.app.slug`, `ctx.version` | Your app and the version the caller has open (the function code is that version's). |
490
- | `ctx.secrets.NAME` | The secrets the function declares in `hawk-app.json` (see below). Nothing else. |
491
- | `ctx.config` | The app's [config](#app-config) for the caller's client and this version, already resolved: `{ key: value }` for every declared key (null when nothing is set and there's no default). Frozen. |
492
- | `ctx.platform` | The platform API **as the caller**: `analytics.report`, `dashboard.get/post/put/delete`, `data.query` (polled for you), [`email.send`](#email-emailsend), [`hawksearch.search/autocomplete`](#hawksearch-search-hawksearchsearch), [`hawksearch.systemParameters`](#hawksearch-system-parameters-hawksearchsysparamswrite), [`functions.start/job/wait`](#background-jobs), [`ai.generate/stream/info`](#ai-aiinvoke) and `api.get/post`, with your app's scopes. Analytics dates don't default on the server: pass them. |
493
- | `ctx.job`, `ctx.trigger` | For a [background job](#background-jobs): `{ id, progress(percent?, message?) }` (null for a call), and what started the run (`call` or `schedule`). |
494
- | `ctx.aws` | Only with `data:read`: short-lived credentials scoped to this client's analytics data, plus `region`, `database`, `workGroup`, `outputLocation`, for the AWS SDK's Athena client. Prefer `ctx.platform.data.query` when an app query does the job. |
495
-
496
- The deploy wraps your `handler` with `toLambdaHandler` from `@bridgeline-digital/hawk-app-bridge/server` and
497
- bundles it with every dependency, so the app needs this package installed (0.4.0 or later). The `/server` entry has
498
- no DOM; `createPlatformClient({ apiBaseUrl, token })` gives you the same `ctx.platform` in tests or scripts.
499
-
500
- **Settings** (per function in `hawk-app.json`; all optional): `job` (a [background job](#background-jobs), default
501
- false), `timeoutSeconds` 1–25 (default 10; 1–900 for a job, default 300), `memoryMb` 128–1024
502
- (default 256; more memory also means more CPU), `secrets` up to 20 names. At most 10 functions per app; names are
503
- lowercase letters, digits and dashes, up to 40 characters. Bundles are limited to 10 MB zipped, the `functions/`
504
- sources to 5 MB.
505
-
506
- **Secrets** are set with the CLI, never in code or in the app: `npx hawk-app secrets set FEED_API_KEY` (input hidden;
507
- `--value-from-env VAR` in CI), at most 4 KB each; the platform stores them encrypted. They belong to the app, not a
508
- version: a change applies to every version at once. A function receives only the secrets it declares; a declared secret
509
- that isn't set fails the call with `secret_missing`.
510
-
511
- **Client secrets** hold values that belong to each client, such as their own email platform's API key. Declare the
512
- names on the function (`"clientSecrets": ["MAILCHIMP_API_KEY"]`, up to 10). The client's users set them in the app,
513
- the function reads the calling client's value, and nobody can read a value back:
514
-
515
- ```ts
516
- const list = await app.clientSecrets.list(); // [{ name, set, updatedAt }]
517
- await app.clientSecrets.set('MAILCHIMP_API_KEY', key); // write-only, at most 4 KB
518
- await app.clientSecrets.delete('MAILCHIMP_API_KEY');
519
- // In the function: ctx.clientSecrets.MAILCHIMP_API_KEY (missing when the client hasn't set it).
520
- ```
521
-
522
- **Logs**: `console.log` / `console.error` in a function go to its log, kept 30 days:
523
- `npx hawk-app logs sync-feed --since 1h --follow`. Never log secrets, tokens or personal data.
524
-
525
- **Errors** come back to the app as `HawkApiError`:
526
-
527
- | Code | Status | Meaning |
528
- |---|---|---|
529
- | `function_failed` | 502 | `handler` threw. The response has only the error's name (`errorType`); the message and stack are in the logs. |
530
- | `function_timeout` | 504 / 408 | The function ran past its `timeoutSeconds` (504), or the call past `timeoutMs` (408, raised by the bridge). |
531
- | `result_too_large` | 502 | The result is over 6 MB. |
532
- | `request_too_large` | 413 | The input is over 256 KB. |
533
- | `too_many_requests` | 429 | Too many calls running at once: `app.functions.call` already retried until `timeoutMs`. |
534
- | `function_not_found` | 404 | The version the caller has open has no such function (deploy it first). |
535
- | `function_is_job` / `function_not_job` | 409 | `call` on a job function (use `start`), or `start` on a normal one. |
536
- | `functions_not_enabled` | 403 | Engineering hasn't enabled functions for the app. |
537
- | `secret_missing` | 500 | A declared secret isn't set: `npx hawk-app secrets set <NAME>`. |
538
-
539
- Good to know: a function runs **as the caller**, for the caller's client, so scope anything you store by
540
- `ctx.client.id`. Functions are stateless and short (25 s at most, or 900 s for a job); keep nothing between calls
541
- that you can't lose.
542
-
543
- ### Background jobs
544
-
545
- Work that takes longer than a call (a full export, a batch of dashboard writes, a nightly report) goes in a **job
546
- function**: `"job": true` in `hawk-app.json`. The app starts it, gets a job id at once, and reads the result later.
547
-
548
- ```jsonc
549
- "functions": {
550
- "export-report": { "job": true, "timeoutSeconds": 600 }
551
- }
552
- ```
553
-
554
- ```ts
555
- // functions/export-report.ts
556
- export const handler: HawkFunction<{ month: string }, { rows: number }> = async (input, ctx) => {
557
- const pages = await loadPages(input.month);
558
- for (const [i, page] of pages.entries()) {
559
- await writePage(page);
560
- await ctx.job?.progress(Math.round(((i + 1) / pages.length) * 100), `Page ${i + 1} of ${pages.length}`);
561
- }
562
- return { rows: pages.length };
563
- };
564
- ```
565
-
566
- ```ts
567
- // In the app
568
- const { jobId } = await app.functions.start('export-report', { month: '2026-09' });
569
- try {
570
- const job = await app.functions.wait<{ rows: number }>(jobId, {
571
- onProgress: (j) => setStatus(j.progress?.message ?? j.status),
572
- });
573
- console.log(job.result!.rows);
574
- } catch (error) {
575
- if (error instanceof FunctionJobError) console.log(error.job.status, error.job.error?.type); // failed / timed_out
576
- }
577
- ```
578
-
579
- - `app.functions.start(name, input?, { signal? })` resolves to `{ jobId }` once the job is queued (202). A job
580
- function can only be started and a normal function only called (409 `function_is_job` / `function_not_job`).
581
- - `app.functions.wait(jobId, { timeoutMs?, intervalMs?, onProgress?, signal? })` checks the job every `intervalMs`
582
- (default 2 s) and resolves with the `succeeded` job; it throws `FunctionJobError` (with `.job`) when the job
583
- `failed` or `timed_out`, and HawkApiError 408 `job_wait_timeout` after `timeoutMs` (default 15 min; the job keeps
584
- running). `onProgress` gets the job whenever its status or progress changes.
585
- - `app.functions.job(jobId)` reads it once: `{ jobId, function, status, progress, result, error, createdAt,
586
- finishedAt, trigger }`, `status` one of `queued`, `running`, `succeeded`, `failed`, `timed_out`. Store the id to
587
- pick the job up again after a reload.
588
- - In the function, `ctx.job` is `{ id, progress(percent?, message?) }` (null for a call). `progress` sends at most
589
- once a second (calls in between are merged) and never throws. `ctx.trigger` says what started the run:
590
- `{ type: 'call' }` or `{ type: 'schedule', schedule, enabledBy }`.
591
- - `ctx.platform.functions.start/job/wait` start other jobs from a function, e.g. to fan work out.
592
-
593
- Limits: a job runs up to 900 s (`timeoutSeconds`, default 300); 3 jobs per app and client run at once (429
594
- `too_many_jobs`, not retried); results up to 256 KB; jobs are kept 7 days (then 404 `job_not_found`, also for
595
- another app's or client's job). A job isn't retried when it fails.
596
-
597
- ### Schedules
598
-
599
- `schedules` in `hawk-app.json` run a job function on a timer. A schedule does nothing until a user turns it on from
600
- the app, for their client: `app.schedules.enable(name)`.
601
-
602
- ```jsonc
603
- "functions": { "nightly-report": { "job": true, "timeoutSeconds": 600 } },
604
- "schedules": {
605
- "nightly": { "function": "nightly-report", "cron": "0 7 ? * MON-FRI *", "timezone": "America/Chicago" }
606
- }
607
- ```
608
-
609
- ```ts
610
- const schedules = await app.schedules.list();
611
- // [{ name: 'nightly', function: 'nightly-report', cron: '0 7 ? * MON-FRI *', timezone: 'America/Chicago',
612
- // enabled: false, enabledBy: null, enabledAt: null, lastRun: null }]
613
- await app.schedules.enable('nightly'); // or disable('nightly'); both resolve to the schedule
614
- ```
615
-
616
- - `cron` is an EventBridge cron with 6 fields (`minutes hours day-of-month month day-of-week year`, with `?` in
617
- day-of-month or day-of-week) and runs **at most hourly** (the minutes field is a single number); `timezone` is an
618
- IANA name, default `UTC`. At most 5 schedules per app, each running a job function of the same manifest.
619
- - A scheduled run is a job (`ctx.trigger.type === 'schedule'`) for the client and site where the user turned the
620
- schedule on, as the user `schedule:<name>` (no roles, not internal), with that client's HawkSearch connection. Its
621
- `lastRun` shows the job it started, or `skipped` with a `reason`.
622
- - Schedules are a setting reviewed like scopes. They stop when the app's release is withdrawn, functions are turned off for
623
- it, or a version without the schedule is approved.
624
-
625
- ## Email: `email:send`
626
-
627
- Send an email from the app or from a [function](#functions-functionsinvoke): a report to the user, an alert, a
628
- digest. The platform sends it; your app never gets mail credentials.
629
-
630
- Add `"email:send"` to `scopes` in `hawk-app.json` and deploy (`hawk-app deploy` sends hawk-app.json with the version): any app with the scope can send. Without it, sends fail
631
- with `scope_missing`.
632
-
633
- ```ts
634
- // functions/weekly-digest.ts: from a function, as the caller
635
- import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
636
-
637
- export const handler: HawkFunction<void, { messageId: string }> = async (_input, ctx) => {
638
- if (!ctx.user.email) throw new Error('The caller has no email address.');
639
- const top = await ctx.platform.analytics.report('searches', { startDate: '2026-09-01', endDate: '2026-09-30', limit: 5 });
640
- const { messageId } = await ctx.platform.email.send({
641
- to: ctx.user.email,
642
- subject: 'Your top searches',
643
- text: top.data.map((row) => JSON.stringify(row)).join('
644
- '),
645
- html: `<p>${top.data.length} rows</p>`,
646
- });
647
- return { messageId };
648
- };
649
- ```
650
-
651
- ```ts
652
- // Or straight from the app
653
- const { messageId, recipients } = await app.email.send({ to: ['a@example.com', 'b@example.com'], subject: 'Export ready', text: 'Your export is ready.' });
654
- ```
655
-
656
- `app.email.send({ to, subject, text?, html?, replyTo? }, { signal? })` (and `ctx.platform.email.send`, the same)
657
- resolves to `{ messageId, recipients }`.
658
-
659
- - **Sender**: `"<App name> (HawkSearch)" <noreply@hawksearch.com>`, with your app's name. Nobody reads replies to it:
660
- set `replyTo` (one address) if recipients should be able to answer.
661
- - **Recipients**: `to` is one address or up to 10 unique ones, plain addresses only (no display names, no cc/bcc).
662
- Apps can email anyone, so only send what the recipient should see, and never secrets.
663
- - **Content**: `subject` 1–200 characters on one line; at least one of `text` / `html`, together at most 256 KB.
664
- - **Limit**: 200 recipients per app and client per UTC day (a 10-recipient send counts 10), for every version of the
665
- app together. Over it, sends fail with `email_limit_reached` until UTC midnight (`error.retryAfterMs`). Sends are
666
- never retried for you.
667
- - **Audit**: every send is logged with the app, version, client, user, recipient count and domains and the message
668
- id. The subject and body are never stored.
669
-
670
- **Errors** come back as `HawkApiError`:
671
-
672
- | Code | Status | Meaning |
673
- |---|---|---|
674
- | `scope_missing` | 403 | The app doesn't have the `email:send` scope. |
675
- | `invalid_email` | 400 | A field is missing or wrong; the response's `errors` (in `error.body`) lists each one. |
676
- | `request_too_large` | 413 | The body is over 256 KB. |
677
- | `email_limit_reached` | 429 | The app's daily limit for this client is used up; `retryAfterMs` is the time until it resets. |
678
- | `email_rejected` | 400 | The mail service refused the message (e.g. an address it can't deliver to). |
679
- | `email_unavailable` | 502 | The mail service failed: try again later. |
680
-
681
- ## HawkSearch search: `hawksearch:search`
682
-
683
- Run real HawkSearch searches and autocompletes for the engine the user is working on, live or in **preview**
684
- (unpublished rules, a future date): before/after checks of a rule change, "do our top queries return results?",
685
- zero-result hunts. From the app or from a [function](#functions-functionsinvoke) (`ctx.platform.hawksearch`).
686
-
687
- ```ts
688
- const results = await app.hawksearch.search({ Keyword: 'boots', MaxPerPage: 24 });
689
- results.Pagination?.NofResults; // HawkSearch's own v2 response
690
-
691
- // Preview: unpublished rules as of a date (the engine's time zone)
692
- await app.hawksearch.search({ Keyword: 'boots', IsInPreview: true, SmartBar: { PreviewDate: '2026-11-27T00:00:00' } });
693
-
694
- await app.hawksearch.autocomplete({ Keyword: 'boo', ProductCount: 5 });
695
- ```
696
-
697
- - The request is HawkSearch's v2 search / autocomplete body, sent as is: any field works (`Keyword`, `PageNo`,
698
- `MaxPerPage`, `SortBy`, `FacetSelections`, `CustomUrl`, `IndexName`, `SmartBar`, …). The platform sets `ClientGuid`
699
- to the current engine's; you never need it.
700
- - `IsInPreview: true` runs on the engine's preview search API.
701
- - At most 600 calls per app and client per minute: a `429 too_many_requests` carries `retryAfterMs` (not retried
702
- for you). For bulk checks, run a few at a time.
703
- - Read-only, so the scope doesn't stop an app from being auto-approved.
704
-
705
- | `error.code` | Status | Meaning |
706
- |---|---|---|
707
- | `scope_missing` | 403 | Add `hawksearch:search` to `scopes`. |
708
- | `engine_required` | 400 | The host session has no engine. |
709
- | `search_not_available` | 404 / 503 | The engine isn't active, or has no search API set up in HawkSearch. |
710
- | `search_needs_site_key` | 403 | The engine requires authenticated searches and the session only has a group API key. |
711
- | `too_many_requests` | 429 | Over the per-minute limit; wait `retryAfterMs`. |
712
- | `hawksearch_unavailable` | 502 | HawkSearch didn't answer: try again. |
713
-
714
- Other HawkSearch errors (e.g. `400` for an unknown `IndexName`) come back as HawkSearch sent them.
715
-
716
- ## HawkSearch system parameters: `hawksearch:sysparams:write`
717
-
718
- Let users change a few HawkSearch **system parameters** of the engine they are working on (Sites → System Parameters
719
- in HawkSearch) from the app or from a [function](#functions-functionsinvoke), e.g. a setup wizard that sets the time
720
- zone and the preview URL.
721
-
722
- Add the scope and list the parameters the app may touch in `hawk-app.json`:
723
-
724
- ```json
725
- {
726
- "scopes": ["hawksearch:sysparams:write"],
727
- "systemParameters": ["Timezone", "SearchPreviewURL"]
728
- }
729
- ```
730
-
731
- The list is reviewed like scopes: it goes with the version, which waits for approval in HawkSearch (`hawk-app status`:
732
- `settings waiting for review: systemParameters`), and only the approved list counts. Up to 50 names (letters, digits, `_`, `.`, `-`; up to 50
733
- characters each), matched case-insensitively. The scope needs a non-empty list, and the list needs the scope.
734
-
735
- ```ts
736
- const { engineId, parameters } = await app.hawksearch.systemParameters.list();
737
- // parameters: [{ name: 'Timezone', label: 'Time zone', group: 'General', type: 'TIMEZONE',
738
- // value: 'Eastern Standard Time', encrypted: false, readOnly: false }, ...]
739
-
740
- const timezone = await app.hawksearch.systemParameters.get('Timezone');
741
-
742
- const { value, previous, appliesWithinSeconds } = await app.hawksearch.systemParameters.set('Timezone', 'UTC');
743
- await app.hawksearch.systemParameters.set('SearchPreviewURL', null); // null or "" clears it
744
- // A declared parameter the engine doesn't have yet: create it (AE only, label/type optional), then set it.
745
- await app.hawksearch.systemParameters.set('packageTier', 'Premium', { create: { group: 'HawkAI Configuration', label: 'Package Tier' } });
746
- ```
747
-
748
- `ctx.platform.hawksearch.systemParameters` in a function is the same, as the caller.
749
-
750
- - **Which engine**: always the user's current engine in the host (`context.engine`), sent by the host when it issues
751
- the app token, never chosen by the app. After the user switches engines, the host issues tokens for the new engine.
752
- Only that engine changes: it is not synchronized to the other engines of a group.
753
- - **Who can set**: any user who can open the app. Every change is logged in HawkSearch (the parameter's history and
754
- the activity log, as `<user> via Hawk Apps (<slug>)`) and in the platform's audit log.
755
- - **Which parameters**: only the declared ones that exist for the engine, including "AE only" ones (shown in red in
756
- HawkSearch). Encrypted, global and read-only parameters are never settable; `list` leaves out global ones and
757
- returns encrypted ones with `value: null`.
758
- - **Values** are strings, checked against the parameter's type: `STRING` (up to 1000 characters), `EMAIL`,
759
- `INTEGER`, `FLOAT`, `YESNO` (`"0"` / `"1"`; `"true"` / `"false"` are accepted), `TIMEZONE` (a Windows time zone id,
760
- e.g. `"Eastern Standard Time"`), `DIMENSION` (`"width;height"`). Other types can't be set yet. Setting the current
761
- value again changes nothing.
762
- - **When it applies**: HawkSearch caches system parameters, so a change applies within about **3 minutes**
763
- (`appliesWithinSeconds`: 180). Calls are never retried for you.
764
-
765
- **Errors** come back as `HawkApiError`:
766
-
767
- | Code | Status | Meaning |
768
- |---|---|---|
769
- | `scope_missing` | 403 | The app doesn't have the `hawksearch:sysparams:write` scope. |
770
- | `engine_required` | 400 | The host didn't send an engine with the token (no engine selected). |
771
- | `engine_not_allowed` | 403 | The engine doesn't belong to the token's client. |
772
- | `parameter_not_found` | 404 | Not declared in `systemParameters`, or the engine has no such parameter (also for global ones; AE-only parameters work when declared). |
773
- | `parameter_read_only` | 403 | The parameter is read-only. |
774
- | `parameter_encrypted` | 400 | Encrypted parameters can't be set. |
775
- | `parameter_type_unsupported` | 400 | Parameters of this type can't be set yet. |
776
- | `invalid_value` | 400 | The value doesn't fit the parameter's type (the message says why). |
777
- | `hawksearch_db_unavailable` | 502 | HawkSearch couldn't be reached: try again later. |
778
- | `sysparams_not_configured` | 503 | System parameters aren't enabled on this platform yet. |
779
-
780
- ## HawkSearch features: `hawksearch:features:write`
781
-
782
- Turn HawkSearch features on and off for the user's current engine: the Feature Configuration's additional features
783
- (`aiassistant`, `didYouMean`, `sortingSets`, …) and facet features (`tooltip`, `swatch`, `collapsible`, …). Add
784
- `"hawksearch:features:write"` to `scopes`; it's elevated (never auto-approved) and reaches every feature, paid add-ons
785
- included.
786
-
787
- ```ts
788
- const { features, facetFeatures } = await app.hawksearch.features.list(); // [{ code, enabled, known }]
789
- await app.hawksearch.features.set('aiassistant', true);
790
- await app.hawksearch.features.setFacet('tooltip', false); // { code, enabled, known, changed, appliesWithinSeconds }
791
- ```
792
-
793
- Each call changes one code and keeps the others. Codes HawkSearch knows are stored in its spelling; any other code of
794
- letters and digits is accepted (a feature HawkSearch adds later works at once) and answered with `known: false`.
795
- Changes apply within about 3 minutes. Errors are those of system parameters, plus `invalid_feature` (400) for a code
796
- that isn't 1-50 letters and digits.
797
-
798
- ## HawkSearch index rebuilds: `hawksearch:index`
799
-
800
- Rebuild the user's current engine's index from its feed, as HawkSearch's Rebuild Index button does, and follow it. Add
801
- `"hawksearch:index"` to `scopes`; it's elevated (never auto-approved), since a rebuild reloads the client's live search.
802
-
803
- ```ts
804
- const run = await app.hawksearch.index.rebuild(); // { notificationId, status: 'new', done: false, … }
805
- const final = await app.hawksearch.index.wait(run.notificationId!, {
806
- onProgress: (r) => setStatus(`${r.status} (${r.count ?? 0} items)`),
807
- }); // { status: 'success' | 'error' | …, done: true, count, durationSeconds }
808
- const latest = await app.hawksearch.index.status(); // the engine's latest rebuild, by anyone
809
- ```
810
-
811
- - **Only where the engine allows it**: HawkSearch's `EnableRebuildIndex` system parameter must be on for the engine
812
- (it's the switch for HawkSearch's own button). Otherwise 403 `index_rebuild_disabled`.
813
- - **One at a time per engine**: while a rebuild is `new`, `pending`, `processing` or `queue` (from this app or anyone),
814
- `rebuild()` answers 409 `index_in_progress` with the running one in `current`. A rebuild is done when its status is
815
- `success`, `error`, `duplicated` or `suspended`.
816
- - **Full rebuilds only**, from the engine's configured feed. There's no cancel, and progress is the status and item
817
- count, not a percentage. Rebuilds take minutes: `wait` checks every 15 s by default, for up to an hour.
818
- - **Site keys only**: with a group key, 403 `hawksearch_index_needs_site_key` (HawkSearch would rebuild the group's
819
- parent site). At most 2 rebuild requests per app and client per minute (429 `too_many_requests`). Every rebuild is
820
- audit-logged (app, client, engine, user).
821
- - Other errors: `index_never_rebuilt` / `index_run_not_found` (404), `index_wait_timeout` (408, from `wait`),
822
- `hawksearch_failed` / `hawksearch_unavailable` / `hawksearch_credentials_rejected` (502).
823
-
824
- ### The Indexing API: `hawksearch:index`, `:index:items`, `:index:manage`
825
-
826
- For engines with their own indexes (HawkSearch's Indexing API), apps can read, change and manage the indexes
827
- themselves. Three scopes, all elevated, so an approver sees exactly what an app can do:
828
-
829
- | Scope | Calls |
830
- | --- | --- |
831
- | `hawksearch:index` | `indexes()`, `current()`, and reads with `call`: `'inactive'`, `'hierarchy'`, `'mappings'`, `'embedding/progress'`, `` `embedding/status/${id}` `` |
832
- | `hawksearch:index:items` | `items.upsert(items)`, `items.update(items)`, `items.delete(ids)`, and with `call`: `'add-attributes-to-items'`, `'update-attributes-of-items'`, `'remove-attributes-from-items'`, `'update-nested'`, `'remove-nested'`, `'bulk-update-nested'`, `'bulk-remove-nested'`, `'bulk-item-operations'` |
833
- | `hawksearch:index:manage` | `create({ suffix })`, `setCurrent(name)`, `deleteIndex(name)`, `rebuildAll(name)`, and with `call`: `'swap'`, `'rebuild-autocomplete'`, `'rebuild-percolator'`, `'rebuild-learning'`, `'rebuild-related'`, `'rebuild-embedding'`, `'hierarchy/upsert'`, `'hierarchy/rebuild'`, `'hierarchy/delete-items'`, `'hierarchy/delete-all'`, `'mappings/upsert'`, `'mappings/invalidate-cache'` |
834
-
835
- ```ts
836
- // Update prices on the live index: each field is an array of values, with the engine's primary key.
837
- const result = await app.hawksearch.index.items.update([{ id: ['sku-1'], price: [79.99] }]); // { Status, Summary, Items }
838
-
839
- // Build a new index, fill it, then switch live search to it.
840
- const name = await app.hawksearch.index.create({ suffix: 'spring' });
841
- await app.hawksearch.index.items.upsert(batch, { indexName: name }); // up to 125 items a call
842
- await app.hawksearch.index.rebuildAll(name);
843
- await app.hawksearch.index.setCurrent(name);
844
- ```
845
-
846
- - **Live at once.** Item writes default to the engine's current index, and HawkSearch applies them immediately. Pass
847
- `indexName` to change another one. HawkSearch checks each item against the engine's fields and primary key: a write
848
- with any failed item is a 400 with HawkSearch's per-item `Status`.
849
- - **Limits:** 125 items (or 1000 ids) per call, 5 MB; 120 item writes and 10 management calls per app and client per
850
- minute (429 `too_many_requests`). Site keys only. Writes are audit-logged (operation, index, count), never retried.
851
- - **Never the live index:** `deleteIndex` of the current index is 409 `index_is_current`. Engines may have only two
852
- indexes: delete the old one before `create`. `unset-current`, index templates and group indexing aren't available.
853
- - `call(operation, body?)` sends HawkSearch's own body
854
- ([Indexing API](https://developerdocs.hawksearch.com/reference/indexv2_get)) and resolves to its answer. Engines
855
- without an Indexing API answer 503 `indexing_api_not_available`; anything outside the list is 404.
856
-
857
- ## HawkSearch recommendations: `hawksearch:recommendations`
858
-
859
- HawkSearch's recommendation widgets for the user's current engine, as its storefront gets them. Add
860
- `"hawksearch:recommendations"` to `scopes` (not elevated: read-only storefront results).
861
-
862
- ```ts
863
- const { widgetItems } = await app.hawksearch.recommendations({
864
- widgetUids: [{ widgetGuid: '8a6d…', uniqueId: 'sku-1' }], // the widget, and the product being viewed (if its model needs one)
865
- contextProperties: { uniqueidlist: 'sku-1,sku-2' }, // e.g. a cart
866
- });
867
- for (const item of widgetItems?.[0]?.recommendationItems ?? []) console.log(item.itemName, item.price);
868
- ```
869
-
870
- The request is HawkSearch's own ([Recommendation API](https://developerdocs.hawksearch.com/docs/recommendation-api)):
871
- `widgetUids` for one context, or `widgets` (up to 10, each with its own `contextProperties`) for the bulk endpoint;
872
- `renderHTML: true` for the widget's HTML; `enablePreview` for preview rules. The platform sets the engine's
873
- `clientGuid`, adds a random `visitorId` and `visitId` when you send none (send the shopper's to personalize), and never
874
- lets the request count in the client's recommendation reports. Widget guids are on HawkSearch's Recommendations page (or
875
- `app.dashboard.get(...)` with `hawksearch:read`). Limited to 600 requests per app and client per minute (429
876
- `too_many_requests`). Errors: `invalid_request` (400), `recommendations_not_configured` (503), `hawksearch_unavailable`
877
- (502).
878
-
879
- ## Storage: `storage`
880
-
881
- Keep the app's own data on the platform: settings, state, history. Each item is a JSON value (at most 350 KB) under a
882
- key, kept for the current client and shared by everyone at that client using the app, and by its functions and
883
- schedules (`ctx.platform.storage`). Add `"storage"` to `scopes` (not elevated).
884
-
885
- ```ts
886
- const config = await app.storage.get<WatchConfig>('config'); // { key, value, version, ... } or null
887
- await app.storage.put('config', { competitors }, { ifVersion: config?.version ?? 0 }); // 409 if someone changed it
888
- await app.storage.put(`hist#${sku}#2026-10`, points, { ttlDays: 400 }); // expires in 400 days
889
- const { items, cursor } = await app.storage.list({ prefix: 'match#', values: true });
890
- await app.storage.delete('draft');
891
- await app.storage.batch({ get: ['a', 'b'], put: [{ key: 'c', value: 1 }], delete: ['d'] }); // up to 25, each answered on its own
892
- const { items: count, bytes, maxBytes } = await app.storage.usage();
893
- ```
894
-
895
- - **Keys:** 1–256 characters from `A–Z a–z 0–9 . _ - : / #`, not starting with `#`, and without empty, `.` or `..`
896
- segments between slashes. Listing by prefix is the only query, so put what you look things up by first.
897
- - **Limits:**
898
- - 100 MB and 100,000 items per app and client: 507 `storage_quota_exceeded` beyond.
899
- - 600 reads and 300 writes per app and client per minute: 429 `too_many_requests` with `retryAfterMs`.
900
- - **Other errors:** 409 `version_conflict`, 413 `value_too_large`.
901
- - **Reviewers' previews** use the client's real data, so version your keys (`v2#config`) when you change their shape.
902
-
903
- **Files** (up to 100 MB each; 5 GB and 50,000 per app and client) go straight between the app and the platform's
904
- bucket through short-lived signed requests:
905
-
906
- ```ts
907
- await app.storage.files.upload('exports/prices.csv', csv, { contentType: 'text/csv', ttlDays: 30 }); // Blob, bytes or text
908
- const { url } = await app.storage.files.url('logos/acme.png'); // a 5-minute link: <img src={url}>, or { download: 'name' }
909
- const blob = await app.storage.files.download('exports/prices.csv');
910
- const { files, cursor } = await app.storage.files.list({ prefix: 'exports/' });
911
- await app.storage.files.delete('exports/prices.csv');
912
- ```
913
-
914
- - **Paths:** segments of `A–Z a–z 0–9 . _ -` between slashes.
915
- - **Expiry:** `ttlDays` is 1 to 365 (rounded up to 1, 7, 30, 90 or 365).
916
- - **Errors:** 413 `file_too_large`, 507 `storage_quota_exceeded`, `upload_failed` when the bucket refuses the upload.
917
- - **Usage:** `usage()` counts files a few seconds after they land.
918
-
919
- See the platform's docs/storage.md.
920
-
921
- ## External Lambdas: `lambdas:invoke`
922
-
923
- Call a Lambda written outside Hawk Apps (for example HawkAnalytics' client setup) from the app or a function. Declare
924
- it by name and add the scope; on a released app the change waits for approval, and Bridgeline Engineering allows each Lambda
925
- on the platform.
926
-
927
- ```json
928
- { "scopes": ["lambdas:invoke"], "lambdas": ["analytics-client-setup"] }
929
- ```
930
-
931
- ```ts
932
- const status = await app.lambdas.invoke('analytics-client-setup', { action: 'status' }); // or ctx.platform.lambdas
933
- ```
934
-
935
- The Lambda receives `{ caller, input }`: `caller` (client, user, engine, roles) comes from the app token, so it can't be
936
- faked from the browser; `input` is what you pass. Calls are never retried. Errors: `lambda_not_allowed` (404),
937
- `too_many_calls` (429, 30 a minute per app and client), `lambda_failed` / `lambda_unavailable` (502), `lambda_timeout`
938
- (408, after `timeoutMs`, default 35 s). See the platform's docs/external-lambdas.md.
939
-
940
- ## App config
941
-
942
- Settings the app reads at runtime that someone changes without a deploy: who gets a notification email, a threshold, a
943
- feature switch. Declare each key, its type and an optional default in `config` in hawk-app.json (the declarations are
944
- reviewed like any setting); the values are set from the Hawk Analytics portal or with `npx hawk-app config set`, for the
945
- whole app (global) or for one client, and apply within about 30 seconds, with no deploy or approval.
946
-
947
- ```json
948
- {
949
- "config": {
950
- "signupNotifyEmails": {
951
- "type": "emails",
952
- "label": "Sign-up notification recipients",
953
- "description": "Who is emailed when a client signs up.",
954
- "default": ["support@example.com"]
955
- },
956
- "connectorsRequestEmails": { "type": "emails", "label": "API Connections request recipients" }
957
- }
958
- }
959
- ```
960
-
961
- ```ts
962
- const all = await app.config.all(); // { signupNotifyEmails: [...], connectorsRequestEmails: null }
963
- const to = await app.config.get<string[]>('signupNotifyEmails'); // the value, or null
964
- // In a function: ctx.config.signupNotifyEmails (already resolved), or ctx.platform.config.get(...)
965
- ```
966
-
967
- - **Not secret:** values reach the browser, so anyone using the app can see them. Keep API keys and passwords in
968
- [secrets](#functions-functionsinvoke) (`hawk-app secrets`, or `app.clientSecrets` for each client's own).
969
- - **Resolution:** the caller's client value, else the global value, else the declared `default`, else `null`. A stored
970
- value that no longer matches the key's type is skipped. Every declared key is in `all()`; `get` returns `null` for a
971
- key the app doesn't declare and throws a `TypeError` for an empty key.
972
- - **Declarations:** at most 50 keys, names `^[A-Za-z][A-Za-z0-9_]{0,63}$`. Each has a `type` (`string`, `number`,
973
- `boolean`, `emails`, `json`), an optional `label` (up to 100 characters), `description` (up to 500) and `default`.
974
- - **Values by type:** `string` up to 4000 characters; `number` finite; `boolean`; `emails` a list of up to 50 distinct
975
- plain addresses (`a@example.com`, no display names); `json` any JSON but `null`. At most 16 KB each.
976
- - **No scope** is needed. Functions get the values for their own version, the app the ones for the version it has open.
977
- - **Set values** with `npx hawk-app config set signupNotifyEmails a@example.com,b@example.com [--client e3141]`,
978
- `config unset`, and `config list`, or in the Hawk Analytics portal.
979
-
980
- | Code | Status | Meaning |
981
- |---|---|---|
982
- | `config_key_not_declared` | 404 | Setting a key that isn't in `config` in hawk-app.json. |
983
- | `invalid_config_value` | 400 | The value doesn't match the key's type. |
984
- | `invalid_client` | 400 | The client id isn't a valid client id. |
985
- | `value_too_large` | 413 | The value is over 16 KB. |
986
-
987
- ## AI: `ai:invoke`
988
-
989
- Ask Claude from the app or from a [function](#functions-functionsinvoke) (`ctx.platform.ai`): summarize a report,
990
- draft synonyms, explain a zero-result query. The platform calls the model; your app never holds a model key.
991
-
992
- Add `"ai:invoke"` to `scopes` in `hawk-app.json` and deploy. It is an **elevated** scope: an app with
993
- it is never auto-approved, and approvers review it like dashboard writes.
994
-
995
- ```ts
996
- // The whole answer at once
997
- const { text, usage } = await app.ai.generate({ prompt: 'Suggest 5 synonyms for "sneakers", one per line.' });
998
-
999
- // Or as it is written
1000
- const stream = app.ai.stream({
1001
- model: 'smart',
1002
- system: 'You are a HawkSearch merchandising assistant. Answer in short bullet points.',
1003
- messages: [{ role: 'user', content: `Explain these zero-result queries: ${queries.join(', ')}` }],
1004
- maxTokens: 800,
1005
- });
1006
- for await (const text of stream) {
1007
- output.textContent += text;
1008
- }
1009
- const { stopReason, usage: { inputTokens, outputTokens } } = await stream.result;
1010
- ```
1011
-
1012
- - **Tiers**: `model: 'fast'` (the default; Claude Haiku: quick, cheap answers) or `'smart'` (Claude Sonnet: harder
1013
- reasoning, longer answers).
1014
- - **Request**: `prompt` (one user message) or `messages` (1–50 `{ role: 'user' | 'assistant', content }`, alternating,
1015
- first and last from the user), plus optional `system` (up to 32 KB), `maxTokens` (default 1024, up to the tier's
1016
- `maxOutputTokens`), `temperature` (0–1) and `stopSequences` (up to 4). At most 256 KB in all.
1017
- - **Result**: `{ text, stopReason, usage: { inputTokens, outputTokens }, id, model }`. `stopReason` is `end_turn`,
1018
- `max_tokens` (raise `maxTokens`), `stop_sequence` or `content_filtered`.
1019
- - **Streaming**: `stream(request)` yields the text as it arrives; iterate it once, then `await stream.result`. Errors
1020
- are thrown by the loop and by `result`. `stream.abort()`, the `signal` option or leaving the loop early (`break`)
1021
- stops the request: both then reject with an `AbortError` (tokens generated so far still count).
1022
- - **Usage**: `app.ai.info()` resolves to `{ tiers: [{ name, maxOutputTokens, dailyTokens, usedTokens }],
1023
- maxInputBytes, requestsPerMinute }`, with today's use for the current client.
1024
- - **Limits**: a daily token budget (input + output) per app, client and tier, reset at UTC midnight, and a per-minute
1025
- request limit per app and client (all tiers). A request is checked against the budget before it runs and counted
1026
- after, so the last one of the day can go over by its own size. Calls are **never retried** for you: each one costs
1027
- tokens.
1028
- - **Privacy**: prompts and answers are never logged or stored by the platform; it logs only the app, version, client,
1029
- user, tier, token counts, duration and outcome. Don't send secrets or personal data you don't need to.
1030
-
1031
- **Errors** come back as `HawkApiError`:
1032
-
1033
- | Code | Status | Meaning |
1034
- |---|---|---|
1035
- | `scope_missing` | 403 | The app doesn't have the `ai:invoke` scope. |
1036
- | `invalid_ai_request` | 400 | A field is missing or wrong; the response's `errors` (in `error.body`) lists each one. |
1037
- | `request_too_large` | 413 | The request is over 256 KB. |
1038
- | `too_many_requests` | 429 | Over the per-minute limit; wait `retryAfterMs`. |
1039
- | `ai_budget_exhausted` | 429 | The tier's daily tokens for this client are used up; `retryAfterMs` is the time until UTC midnight. Try the other tier, or wait. |
1040
- | `ai_not_configured` | 503 | AI isn't enabled on this platform. |
1041
- | `ai_unavailable`, `ai_throttled`, `ai_failed` | 502 | The model failed while answering (the stream's `error`): try again later. Text already streamed is incomplete. |
1042
-
1043
- ## Your own backend
1044
-
1045
- If your app has a server of its own (an API, a database, a job), it can trust who is calling and act for them with
1046
- the **app token**, without any login of its own.
1047
-
1048
- ```ts
1049
- // In the app: send the token as a Bearer header on every call to your backend.
1050
- const response = await fetch('https://my-tool-api.example.com/reports', {
1051
- headers: { Authorization: `Bearer ${await app.getToken()}` },
1052
- });
1053
- ```
1054
-
1055
- ```ts
1056
- // On your server (Node, with `jose`): verify it against the platform's public keys.
1057
- import { createRemoteJWKSet, jwtVerify } from 'jose';
1058
-
1059
- const keys = createRemoteJWKSet(new URL('https://<platform>/.well-known/jwks.json'));
1060
-
1061
- const { payload } = await jwtVerify(token, keys, {
1062
- issuer: 'urn:hawk-apps:prod', // urn:hawk-apps:dev on dev
1063
- audience: 'hawk-app:my-tool', // your slug
1064
- algorithms: ['RS256'],
1065
- });
1066
- // payload.client_id, payload.sub, payload.email, payload.roles, payload.scope ...
1067
- ```
1068
-
1069
- **Auth is a Bearer token, never a cookie.** Your app runs in a cross-site iframe, where browsers block or partition
1070
- cookies (Safari always, Chrome increasingly), so a session cookie that works when you open the app directly fails
1071
- inside the HawkSearch dashboard or Spark. Ask for a fresh token per request: `app.getToken()` caches it and refreshes it before it expires.
1072
-
1073
- The token contract is stable, and any JWT library that reads a JWKS can check it:
1074
-
1075
- | Claim | Value |
1076
- |---|---|
1077
- | header `alg` / `kid` | `RS256`, and the id of the key in the JWKS (keys rotate; let your library pick by `kid`) |
1078
- | `iss` | `urn:hawk-apps:dev` or `urn:hawk-apps:prod` |
1079
- | `aud` | `["hawk-apps-api", "hawk-app:<slug>"]`: check for your own `hawk-app:<slug>` |
1080
- | `exp`, `iat`, `nbf` | about 10 minutes of validity |
1081
- | `sub`, `email` | the user (id, and email when the host has one) |
1082
- | `client_id`, `account_id` | the tenant (`account_id` when the host has one): scope everything you store by `client_id` |
1083
- | `analytics` | `"none"` when the client has no analytics data set up (missing otherwise) |
1084
- | `roles`, `internal` | the user's roles, and whether they are Bridgeline staff |
1085
- | `app`, `host`, `scope` | your slug, the host (e.g. `spark`), the granted scopes |
1086
- | `jti` | a unique id for the token |
1087
-
1088
- Your server can also call the platform with the same token (`Authorization: Bearer <token>`, e.g.
1089
- `POST https://<platform>/v1/analytics/searches`), so data can be fetched server-side for the current user and client.
1090
- Treat the token like a password: don't log it or store it.
1091
-
1092
- Server-rendered apps (SSR, where the server needs the user on the very first page load) are planned: the manifest
1093
- already has `"hosting": "static"`, and `"server"` is reserved for them.
1094
-
1095
- ## Errors
1096
-
1097
- Failed data calls throw `HawkApiError` with `status` and `code`; failed host requests throw `HawkBridgeError` with
1098
- `code`.
1099
-
1100
- | Code | Meaning |
1101
- |---|---|
1102
- | `not_embedded` | The page was opened directly, not inside a host. |
1103
- | `host_not_allowed` | The host isn't in `allowedHostOrigins`. |
1104
- | `timeout` | The host didn't answer in time. |
1105
- | `report_not_allowed` | The report name isn't a plain kebab-case name, or the platform blocks that report. Any other name is passed to the analytics API (`report('')` is its ad-hoc / natural-language root); a name it doesn't know comes back as its own `400`. |
1106
- | `analytics_not_available` | Analytics isn't set up for this client (at once when `context.tenant.analyticsProvisioned` is `false`). |
1107
- | `hawksearch_not_available` | No HawkSearch access: add `hawksearch:read` to your scopes and deploy again. |
1108
- | `version_not_allowed` | You put an API version in a dashboard path: use `Synonym`, not `v11/Synonym`. |
1109
- | `path_not_allowed` | The dashboard path isn't allowed (writes: see [Writing to HawkSearch](#writing-to-hawksearch-hawksearchwrite)). |
1110
- | `hawksearch_write_needs_site_key`, `too_many_items`, `too_many_requests`, `invalid_path` | Dashboard writes: see [Writing to HawkSearch](#writing-to-hawksearch-hawksearchwrite). |
1111
- | `query_*`, `invalid_params`, … | Custom queries: see [Custom data (queries)](#custom-data-queries-dataread). |
1112
- | `function_*`, `functions_not_enabled`, `secret_missing`, … | Functions: see [Functions](#functions-functionsinvoke). |
1113
- | `email_*`, `invalid_email` | Email: see [Email](#email-emailsend). |
1114
- | `config_key_not_declared`, `invalid_config_value` | Config values: see [App config](#app-config). |
1115
- | `engine_*`, `parameter_*`, `invalid_value`, … | System parameters: see [HawkSearch system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite). |
1116
- | `ai_*`, `invalid_ai_request` | AI: see [AI](#ai-aiinvoke). |
1117
- | `*_unavailable`, `*_credentials_rejected` | Something is wrong on our side: retry later, and tell Engineering if it persists. |
1118
-
1119
- A `403` without one of these codes means the app lacks the scope for that call.
1120
-
1121
- ## Errors and usage
1122
-
1123
- `createApp` reports, on its own, one open per page load (the full app or the view showing) and the page's uncaught
1124
- errors and unhandled rejections: at most 10 per page load, each once, without query strings. The app's team sees them
1125
- with `hawk-app errors` and `hawk-app usage`, and Bridgeline in the portal (platform/docs/telemetry.md).
1126
-
1127
- ```ts
1128
- try {
1129
- await app.functions.call('refresh', {});
1130
- } catch (error) {
1131
- app.reportError(error); // reported like an uncaught one, and the app shows its own message
1132
- showMessage('Couldn’t refresh: try again.');
1133
- }
1134
- ```
1135
-
1136
- `createApp({ reportErrors: false, reportOpens: false })` turns either off.
1137
-
1138
- ## Talk to the host
1139
-
1140
- ```ts
1141
- await app.setTitle('Synonyms review'); // the host's page title
1142
- await app.toast('Export ready', 'success'); // info | success | warning | error
1143
- await app.navigate({ intent: 'app', slug: 'other-tool' }); // also: 'report', 'settings', 'external' (https only)
1144
- await app.resize(); // when autoResize is off
1145
- ```
1146
-
1147
- ### Ask the host's assistant: `assistant`
1148
-
1149
- With the `assistant` scope, an app asks the host's assistant (HawkSearch: Hawk Assistant) a question as the user. The
1150
- user sees the question and the answer in the assistant's chat, so call it only from a click:
1151
-
1152
- ```ts
1153
- // Show the button only while the host has an assistant: it may load after the app (HawkSearch loads it through GTM).
1154
- const [ready, setReady] = useState(!!app.context.features?.assistant);
1155
- useEffect(() => app.onContextChange((c) => setReady(!!c.features?.assistant)), [app]);
1156
-
1157
- <Button disabled={!ready} onClick={() => app.assistant.ask('Which recommendation widgets earn the most this quarter?')}>
1158
- Ask Hawk Assistant
1159
- </Button>
1160
- ```
1161
-
1162
- Give the assistant what's on screen with `app.assistant.setContext({ ... })` (a JSON object, at most 50 KB; `null`
1163
- clears it): it goes with every question, the app's and the ones the user types, until the app replaces or clears it, and
1164
- the host clears it when the app closes. Keep it to the figures on screen, in plain names and numbers, and keep the
1165
- questions themselves plain:
1166
-
1167
- ```ts
1168
- useEffect(() => void app.assistant.setContext({ page: 'Recommendation Dashboard', range, totals, widgets }), [range, totals, widgets]);
1169
- await app.assistant.ask('Which widgets are taking up space?');
1170
- ```
1171
-
1172
- A message is at most 2,000 characters. Errors (`HawkBridgeError`): `unsupported` (no assistant on the page), `scope_missing`,
1173
- `invalid_request`. Hosts: `<hawk-app-frame>` fires a cancelable `hawk-assistant` event (`detail.message`; `hawk-assistant-context` with `detail.context` for the context); a host that
1174
- answers it itself calls `preventDefault()` and sets the element's `assistant` attribute. Otherwise the element sends the
1175
- message to `window.HawkAI.Assistant.sendMessage` (the context to `setContext`, kept until the assistant loads) when the page has it, and tells the app (a `contextChanged` with
1176
- `features.assistant`) when it loads.
1177
-
1178
- | Intent | Goes to |
1179
- | --- | --- |
1180
- | `{ intent: 'app', slug, params? }` | Another app |
1181
- | `{ intent: 'report', id? }` | The report list (HawkSearch: Report Library); with an `id`, that report where the host supports it |
1182
- | `{ intent: 'settings', section? }` | The host's settings |
1183
- | `{ intent: 'external', url }` | An https page in a new tab |
1184
-
1185
- Navigation takes *intents*, not URLs, so the same app works in every host.
1186
-
1187
- ## Look and feel
1188
-
1189
- Every template gets this from the UI kit ([`@bridgeline-digital/hawk-app-ui`](../ui/README.md)), Spark's look in
1190
- the host's colors and mode: a React + MUI app its theme, provider and components; any other app (vanilla, Preact,
1191
- React, Next) its stylesheet and custom elements. The rest of this section is for apps that style themselves.
1192
-
1193
- With `applyTheme` on, the host's design tokens are set as CSS variables on `<html>`, and
1194
- `<html data-hawk-theme="light|dark">` matches the host. Use them with your own fallbacks so the app also renders on
1195
- its own:
1196
-
1197
- ```css
1198
- body {
1199
- background: var(--hawk-color-background, #f6f7f9);
1200
- color: var(--hawk-color-text-default, #1f2933);
1201
- font-family: var(--hawk-font-default, system-ui, sans-serif);
1202
- }
1203
- button {
1204
- background: var(--hawk-color-button-accent-background, #ff6a13);
1205
- color: var(--hawk-color-button-accent-text, #fff);
1206
- border-radius: var(--hawk-border-radius, 6px);
1207
- }
1208
- ```
1209
-
1210
- Available: `--hawk-color-background`, `--hawk-color-background-container`, `--hawk-color-background-primary`,
1211
- `--hawk-color-background-accent`, `--hawk-color-text-default`, `--hawk-color-text-heading`,
1212
- `--hawk-color-text-secondary`, `--hawk-color-text-accent`, `--hawk-color-border-default`, `--hawk-color-border-accent`,
1213
- `--hawk-color-button-accent-background`, `--hawk-color-button-accent-text`, `--hawk-font-default`,
1214
- `--hawk-border-radius`.
1215
-
1216
- The host already shows your app's name above the frame, so you don't need a page heading.
1217
-
1218
- ### Dialogs cover the whole page
1219
-
1220
- An open dialog (any `[role="dialog"]`, e.g. every MUI Dialog or Drawer) covers the host's whole page, not just the
1221
- frame, with nothing to call:
1222
- - **A frame that's fully on screen and big enough**: the host dims the page around the frame to match your dialog's
1223
- backdrop (`modalBackdrop` on the host, default MUI's `rgba(0, 0, 0, 0.5)`). A click there sends Escape to the dialog.
1224
- - **Any other frame** (a preview, a slot, a short or narrow frame, or a tall page scrolled partly out of view, where a
1225
- dialog fixed to the frame could open off screen): the host lifts the frame over the whole window while the dialog is
1226
- open. The bridge keeps your page exactly where the frame was (same width, same scroll, clipped to the frame's box)
1227
- on an otherwise transparent page, so the dialog shows full size over the dimmed host page with your content still
1228
- behind it; the frame goes back when the dialog closes.
1229
- - The host page doesn't scroll meanwhile and keeps its scroll position, and auto-resize waits for the dialog to close.
1230
- - Center dialogs in the visible part of the frame (`--hawk-visible-top` and `--hawk-visible-height`, see the templates'
1231
- theme). `createApp({ hostModals: false })` turns this off.
1232
-
1233
- ## Local development
1234
-
1235
- Run your dev server on port 3100 (the templates do), then open the app in HawkSearch with `?devUrl=` pointing at it:
1236
-
1237
- ```
1238
- <HawkSearch>/admin/apps/my-tool?devUrl=http://localhost:3100/my-tool/
1239
- ```
1240
-
1241
- The HawkSearch dashboard loads your local copy instead of the deployed one, with the real context and real data, and hot reload works as
1242
- usual. Dev mode is for Bridgeline users only and accepts only `http://localhost` addresses. The app must be registered
1243
- for the client you're on (a draft is enough), and the dev platform accepts data calls from ports 3100 to 3103.
1244
-
1245
- ## `hawk-app.json`
1246
-
1247
- The CLI sends this file to the platform with every `hawk-app deploy` (and `hawk-app dev`). It sets your app's visibility and what data it can read.
1248
- Which clients have the app is not in this file: manage them in the Hawk Analytics portal or with `npx hawk-app clients`.
1249
-
1250
- ```json
1251
- {
1252
- "slug": "my-tool",
1253
- "name": "My Tool",
1254
- "description": "What it does, in one line.",
1255
- "visibility": "allowList",
1256
- "scopes": ["reports:read"]
1257
- }
1258
- ```
1259
-
1260
- | Field | |
1261
- |---|---|
1262
- | `slug` | Unique id and the app's folder on the CDN (`/<slug>/`, one `v<n>/` folder per deploy). Can't change later. |
1263
- | `icon` | An `.svg` (drawn in the host's color) or `.png` (shown as is) file next to `hawk-app.json`, e.g. `icon.svg`. New apps get a default `icon.svg`; without one, hosts show a default icon. |
1264
- | `hosting` | `static` (the default): built files on the apps CDN. `server` is reserved for server-rendered apps and not accepted yet. |
1265
- | `visibility` | `allowList` (only the clients given the app), `all` (every client except excluded ones), or `internal` (Bridgeline staff). The clients themselves are managed in the Hawk Analytics portal or with `npx hawk-app clients add`/`exclude`/`remove`; a `hawk-app.json` with `clients` or `excludeClients` is refused. |
1266
- | `listed` | `true` (the default) shows the app on the host's Apps page. `false` leaves it off that list, and the host hides its own title and back button for it, but it still opens by its direct URL (e.g. linked from another app or page). It doesn't change who can open it: `visibility` and the app's clients still apply. |
1267
- | `scopes` | `reports:read` for analytics, `hawksearch:read` for the dashboard API, `hawksearch:write` to [write to it](#writing-to-hawksearch-hawksearchwrite), `data:read` for [custom queries](#custom-data-queries-dataread), `functions:invoke` for [functions](#functions-functionsinvoke), `email:send` for [email](#email-emailsend), `hawksearch:sysparams:write` for [system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite), `hawksearch:search` for [search](#hawksearch-search-hawksearchsearch), `hawksearch:features:write` for [features](#hawksearch-features-hawksearchfeatureswrite), `hawksearch:index` for [index rebuilds](#hawksearch-index-rebuilds-hawksearchindex), `hawksearch:index:items` and `hawksearch:index:manage` for [the Indexing API](#the-indexing-api-hawksearchindex-indexitems-indexmanage), `hawksearch:recommendations` for [recommendations](#hawksearch-recommendations-hawksearchrecommendations), `storage` for [storage](#storage-storage), `lambdas:invoke` for [external Lambdas](#external-lambdas-lambdasinvoke), `ai:invoke` for [AI](#ai-aiinvoke), `assistant` to [ask the host's assistant](#ask-the-hosts-assistant-assistant). |
1268
- | `functions` | Your [functions](#functions-functionsinvoke) and their settings: `{ "<name>": { "job", "timeoutSeconds", "memoryMb", "secrets" } }`. Recorded with each version. |
1269
- | `schedules` | [Schedules](#schedules) for job functions: `{ "<name>": { "function", "cron", "timezone" } }`, at most 5, at most hourly. Needs `functions:invoke`. Reviewed like scopes. |
1270
- | `systemParameters` | The HawkSearch [system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite) the app may read and set, e.g. `["Timezone"]`. Needs `hawksearch:sysparams:write`. Reviewed like scopes. |
1271
- | `config` | [App config](#app-config) keys: `{ "<key>": { "type", "label", "description", "default" } }`, at most 50. The values are set outside the file (portal or `npx hawk-app config`) and aren't secret. Reviewed like scopes. |
1272
- | `externalOrigins` | Outside origins for scripts, styles, fonts, images, frames and API calls. New apps start with `[]` (none; the vanilla template: `["https://cdn.jsdelivr.net"]`). `["https://api.example.com", "https://*.cdn.example.com"]` allows only those. `["*"]` (also what a missing field means) allows any https origin and fails the review check. https or wss, no paths. Entries starting with `img:` are for images only: `"img:*"` (any https origin, e.g. a client's product images) or `"img:https://cdn.example.com"`. |
1273
-
1274
- Run `npx hawk-app deploy --dry-run` to see what would change without making a version (use it on pull requests), and
1275
- `npx hawk-app clients` to see which clients have the app.
1276
-
1277
- **Who releases it.** There's no `status` field (an old one is ignored with a warning): every `hawk-app deploy` is a
1278
- new version, with the hawk-app.json it was deployed with, and Bridgeline staff review apps on the Apps page in
1279
- HawkSearch. See [versions, statuses and aliases](../../docs/versions-and-aliases.md).
1280
- - Each version has a status: **draft** (deployed), **waiting** (submitted for approval), **approved** or **rejected**.
1281
- Only Bridgeline staff see versions that aren't approved, on the app's clients (or every client with
1282
- `"visibility": "all"`).
1283
- - An approver **releases** a version: released to everyone, the built-in `default` alias moves to it, and every
1284
- client without another alias gets it. Other aliases (e.g. `pilot`) let some clients get a different version.
1285
- - Once released, each new version and each settings change (name, scopes, `externalOrigins`, …) waits for approval,
1286
- and clients keep the released version until then. `npx hawk-app status` shows what's waiting.
1287
- - Client changes (Hawk Analytics portal or `npx hawk-app clients`) apply at once, without a deploy or approval, and
1288
- are recorded in the app's history.
1289
- - Approvers can set an app to **auto-approve**, so new versions and changes are released straight away.
1290
- - Apps using queries, functions, system parameters, dashboard writes, AI, external Lambdas, HawkSearch features or index
1291
- rebuilds are never auto-approved.
1292
-
1293
- **Approval.** Members of the Hawk-App-Developers group submit a draft, new version or settings change for approval in HawkSearch; members of
1294
- the Hawk-App-Approvers group approve, reject (with a reason) or force approve it. A new deploy or settings change clears a
1295
- waiting request, so it has to be submitted again. `npx hawk-app status` shows whether it's waiting for approval or was
1296
- rejected, and why.
1297
-
1298
- ## Good to know
1299
-
1300
- - **No secrets in the app.** Everything in it is public. Credentials stay on the platform; API keys of your own go in
1301
- function secrets (see [Functions](#functions-functionsinvoke)).
1302
- - **Outside origins are up to you.** An app can load scripts, styles, fonts and images from, embed, and call
1303
- (`fetch`, WebSockets) only the origins in `externalOrigins` (`"*"` allows any https origin, and fails the review
1304
- check). List the origins it really uses: the apps CDN blocks everything else (a Content-Security-Policy), so a compromised npm dependency can't send
1305
- data anywhere new. The platform API is always allowed. To show images from any site (for example a client's
1306
- product images) without opening anything else, use `"img:*"`. Dev mode on localhost has no such policy, so check a
1307
- deployed version in HawkSearch before it's released.
1308
- - **Static files only.** Build to plain HTML, JS and CSS (`next build` with `output: 'export'`, `vite build`, …), with
1309
- the base path read from `HAWK_APP_BASE_PATH` (`/<slug>/v<n>`, set by `hawk-app deploy`; the templates do this).
1310
- - **Tokens are handled for you.** `app.getToken()` exists for apps with a backend of their own; the token can be
1311
- verified against the platform's JWKS.
1312
- - **Changes to client data are reviewed.** Apps change client data only through scopes that allow it:
1313
- `hawksearch:write`, `hawksearch:sysparams:write`, `hawksearch:features:write`, the `hawksearch:index` scopes and
1314
- `storage` (the app's own data). An approver reviews each one.
1315
-
1
+ # @bridgeline-digital/hawk-app-bridge
2
+
3
+ Build **Hawk Apps**: small tools that run inside the HawkSearch dashboard. Your app gets the
4
+ signed-in user's context (client, engine) and the client's data through this package, with **no API
5
+ keys in your code**.
6
+
7
+ Guides, the full API reference and the CLI are at **[developers.hawksearchapps.com](https://developers.hawksearchapps.com)**
8
+ (sign in with your Bridgeline account).
9
+
10
+ - Any static front end works: React, Next.js (static export), Vue, plain HTML. Apps are static files on the apps CDN;
11
+ there is no server of your own.
12
+ - Pair it with the CLI, [`@bridgeline-digital/hawk-app-cli`](https://www.npmjs.com/package/@bridgeline-digital/hawk-app-cli),
13
+ to create, run locally and deploy apps.
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ npx @bridgeline-digital/hawk-app-cli@latest create my-tool --template react --install
19
+ cd my-tool
20
+ npx hawk-app sso # once per machine: sign in with your Bridgeline account
21
+ npm run app:dev # the app in a local stand-in for HawkSearch, with a client's data
22
+ ```
23
+
24
+ The first time, `app:dev` asks for part of a client's name and saves your pick in `.env.local`
25
+ (`npm run app:dev -- --client <id>` picks another).
26
+
27
+ The template is already wired to this package. You can also open it inside HawkSearch in
28
+ [dev mode](#local-development). When it's ready:
29
+
30
+ ```bash
31
+ npm run app:deploy # build and upload a new version, with hawk-app.json (no folder needed)
32
+ npx hawk-app open # the app in HawkSearch: review it and submit it for approval
33
+ ```
34
+
35
+ New versions are drafts that only Bridgeline staff see; an approver releases them from the Apps page in HawkSearch (see
36
+ [`hawk-app.json`](#hawk-appjson)).
37
+
38
+ ## Connect to the host
39
+
40
+ ```ts
41
+ import { createApp } from '@bridgeline-digital/hawk-app-bridge/client';
42
+
43
+ const app = await createApp({ autoResize: true });
44
+
45
+ render(app.context);
46
+ app.onContextChange((context) => render(context)); // the user switched client, engine or dates
47
+ ```
48
+
49
+ **Where the app is visible.** A frame that grows with its content (auto-resize) can be much taller than the screen, so
50
+ a dialog centered in the frame may open off screen. The host reports the visible part of the frame: `app.viewport`
51
+ (`{ top, height }` in px from the frame's top), `app.onViewportChange(listener)`, and the CSS variables
52
+ `--hawk-visible-top` and `--hawk-visible-height`. The UI kit's theme (`@bridgeline-digital/hawk-app-ui`, in the next-mui template) uses them to place MUI dialogs and
53
+ snackbars; other apps can do the same in CSS, e.g.
54
+ `.modal { position: absolute; top: calc(var(--hawk-visible-top, 0px) + 40px); }`.
55
+
56
+ `createApp` resolves once the host has answered with the context. It rejects with code `not_embedded` when the page
57
+ is opened directly instead of inside a host: show a friendly message (the templates do).
58
+
59
+ You don't configure which hosts may embed your app: the platform does. The apps CDN only lets the HawkSearch dashboard and Spark frame
60
+ an app, and the bridge only talks to the window that embedded it.
61
+
62
+ | Option | Default | |
63
+ |---|---|---|
64
+ | `autoResize` | `false` | Keep the frame as tall as your content. Leave it off for full-height layouts. |
65
+ | `applyTheme` | `true` | Apply the host theme as `--hawk-*` CSS variables. |
66
+ | `timeoutMs` | `10000` | How long to wait for the host to answer a request. |
67
+
68
+ ## Context
69
+
70
+ `app.context` is always current; `onContextChange` tells you when it changes.
71
+
72
+ | Field | Example | |
73
+ |---|---|---|
74
+ | `tenant.clientId` | `"e3141"` | The client the user is working in. All data calls are scoped to it automatically. |
75
+ | `tenant.analyticsProvisioned` | `false` | `false` when the client has no analytics data set up: show a message instead of calling `app.data` or `app.analytics` (they'd fail with `data_not_available` / `analytics_not_available`). Missing when the host doesn't say. |
76
+ | `engine` | `{ id: "3141", name: "Outdoor Demo" }` | The engine selected in the host, if any. |
77
+ | `site` | `{ id, name }` | The site, if the host has one selected. |
78
+ | `dateRange` | `{ from: "2026-09-01", to: "2026-09-30", timeZone: "UTC" }` | The host's date picker. Analytics calls use it by default. |
79
+ | `user` | `{ id, email, roles }` | The signed-in user. |
80
+ | `locale` | `"en-US"` | Format numbers and dates with it. |
81
+ | `theme` | `{ mode: "light", tokens }` | Also applied as CSS variables (see [Look and feel](#look-and-feel)). |
82
+ | `features` | `{ assistant: true }` | What the host offers the app, e.g. `features.assistant`: the host's Hawk Assistant can be opened (see [Ask the host's assistant](#ask-the-hosts-assistant-assistant)). |
83
+ | `view` | `{ name: "preview", params, embedded: true, maxHeight }` | The view showing, or null for the full app (see [Views](#views-an-app-inside-other-pages)). |
84
+ | `app.slug` | `"my-tool"` | Your app. |
85
+
86
+ ## Views: an app inside other pages
87
+
88
+ Besides its own page, an app can show **views**: pages of the same build that hosts show inside other pages. Declare
89
+ them in `hawk-app.json`:
90
+
91
+ ```json
92
+ "views": {
93
+ "preview": { "path": "views/preview", "title": "Price position", "height": 220, "cacheMinutes": 60 },
94
+ "product": { "path": "views/product", "title": "This product", "embeddable": true, "params": ["sku"] }
95
+ }
96
+ ```
97
+
98
+ - **`preview`** is shown on the Apps page under the app's description.
99
+ - **Embeddable views** can be placed by Bridgeline users in other pages (the report builder, later Spark pages).
100
+ - **Each view is a page at `<entry>/<path>/`,** e.g. a Next.js route `src/app/views/preview/page.tsx`, with the same
101
+ version, scopes and token as the app.
102
+ - **`app.context.view`** says which view is showing and what the page passed in:
103
+ `{ name, params: { sku: 'A-1' }, embedded: true, maxHeight }`. It's null for the full app.
104
+ - **Keep views small and fast.** A `preview` shares the Apps page with every other app, so read the app's stored
105
+ results rather than run reports or queries.
106
+ - **`cacheMinutes` caches a view's reads** (0–1440; a `preview` caches 60 minutes unless it says otherwise, other views not at all): the platform keeps the answers to the view's data
107
+ queries, analytics reports, `dashboard.get`, search and recommendations for that long, per client and engine, and
108
+ answers the same call again from them. Set `"cacheMinutes": 0` on a preview that must always be fresh. The full app page is never cached,
109
+ and a new version starts fresh. A cached `data.query` result has `cachedAt` (when it was stored). Pass
110
+ `{ cache: 'reload' }` to ask the source again (and store the new answer), or `{ cache: 'no-store' }` for a call
111
+ that must not be cached, e.g. an analytics report that records something. Engineering can shorten or stop it per
112
+ app in the Hawk Analytics portal.
113
+ - **`views` is a setting:** on a released app, changes wait for approval.
114
+ - **A view's page is transparent:** the bridge marks it `data-hawk-view="<name>"` on `<html>` and clears the page
115
+ background, so the host's shows through. To paint one, set it on the view's content, or with
116
+ `html[data-hawk-view] body { … }`.
117
+ - **A view fits its content:** it starts at its declared `height`, then auto-resize (on by default in a view) sizes it
118
+ to the content, within the container's `min-height` and `max-height`. Don't set `html, body { height: 100% }` in a
119
+ view, or it can't shrink.
120
+ - **In a view**, toasts and title changes are ignored (the page isn't the app's), the height grows with
121
+ `app.resize()` up to `maxHeight`, and `app.navigate({ intent: 'app', slug })` opens the full app.
122
+ - **The trial view** of a store app (`"trial": true`, one at most, no params) runs on its store card for clients that
123
+ don't have the app, on their own data. There `app.context.trial` is `true` and the token has only the app's
124
+ read-only scopes (`reports:read`, `hawksearch:read`, `hawksearch:search`, `hawksearch:recommendations`,
125
+ `data:read`): hide or disable what writes, sends, stores or asks the assistant, and say it's a trial.
126
+ `navigate({ intent: 'app' })` opens the host's request dialog.
127
+
128
+ ### Hosts: `<hawk-app-container>`
129
+
130
+ Configure once per page, then place containers anywhere:
131
+
132
+ ```ts
133
+ import { configureHawkAppContainers, defineHawkAppContainer } from '@bridgeline-digital/hawk-app-bridge/host';
134
+
135
+ defineHawkAppContainer();
136
+ configureHawkAppContainers({
137
+ // From the registry: the app's entry URL plus the view's path; null hides the view. { trial }: the store listing's trial view.
138
+ resolveView: async (app, view, options) => ({ src, title, height, appName, description, params }),
139
+ // The app's context now and on every change; return a function that stops the updates.
140
+ watchContext: (app, listener) => subscribe(app, listener),
141
+ // { trial }: a trial token (POST /token/exchange with trial: true).
142
+ getToken: (app, { forceRefresh, trial }) => exchangeToken(app, forceRefresh, trial),
143
+ // { trial }: the client doesn't have the app: offer a request instead.
144
+ openApp: (app, params, options) => (options?.trial ? offerRequest(app) : router.open(app, params)),
145
+ });
146
+ ```
147
+
148
+ ```html
149
+ <hawk-app-container app="price-watch" view="preview" max-height="320" fallback="Compare your prices."></hawk-app-container>
150
+ ```
151
+
152
+ - **Attributes:** `app`, `view` (default `preview`), `params` (a JSON object; only names the view declares are passed),
153
+ `full-app` (the entire app instead of a view: `resolveView(app, null)`; its context has no `view`),
154
+ `min-height` (default 0), `max-height` (default 1200), `fallback` (text when the view can't load and the app has no description),
155
+ `trial` (a store app's trial view: the host's `resolveView`, `getToken` and `openApp` get `{ trial: true }`, and
156
+ the context `trial: true`).
157
+ - **Loading:** when scrolled near, at most `maxConcurrent` (default 3) at a time; a placeholder of the view's height
158
+ until it connects; the fallback after `loadTimeoutMs` (default 20 s).
159
+ - **Styling:** the `state` attribute (`idle`, `loading`, `ready`, `fallback`) and a `hawk-view-state` event; the
160
+ placeholder uses `--hawk-app-container-radius` and `--hawk-app-container-placeholder`.
161
+
162
+ ### Hosts: `<hawk-app-slot>`
163
+
164
+ A place in a host page where Bridgeline users put an app view, saved on the platform per engine or for every client:
165
+
166
+ ```ts
167
+ import { configureHawkAppSlots, defineHawkAppSlot } from '@bridgeline-digital/hawk-app-bridge/host';
168
+
169
+ defineHawkAppSlot(); // also defines <hawk-app-container>: configure it as above
170
+ configureHawkAppSlots({
171
+ canEdit: isBridgelineUser,
172
+ getSlots: (ids) => backend.get(`/slots?ids=${ids}`), // the platform's GET /slots, through the host backend
173
+ listChoices: async () => appsWithPlaceableViews, // [{ app, name, views: [{ name, title, params }] }]
174
+ saveSlot: (slot, change) => backend.put(`/slots/${slot}`, change), // { scope, app, view, params, minHeight, maxHeight }
175
+ removeSlot: (slot, scope) => backend.delete(`/slots/${slot}?scope=${scope}`),
176
+ appHref: (app) => `/apps/${app}`, // the app's page: the icon link's href
177
+ openApp: (app) => router.open(app), // a plain click on the icon link
178
+ });
179
+ ```
180
+
181
+ ```html
182
+ <hawk-app-slot slot-id="dashboard.top"></hawk-app-slot>
183
+ ```
184
+
185
+ - **Clients** see the placement in a `<hawk-app-container>` (light DOM: style it with `hawk-app-slot > hawk-app-container`);
186
+ an empty slot is `hidden`.
187
+ - **Editors** (`canEdit`) get a pencil button (a "Place an app" placeholder when empty) opening a form: app, view (or
188
+ *Entire app*: the app's own page, `<hawk-app-container full-app>`), the view's params, min and max height, *Show a
189
+ link to open the app* (an icon in the slot's corner; not for the entire app), and *This engine only* or *All
190
+ clients*. It fires `hawk-slot-change` after a save
191
+ or remove.
192
+ - **Slots on a page load in one call** (`getSlots` gets every id requested in the same task).
193
+ - **Texts:** `labels` (defaults `DEFAULT_SLOT_LABELS`). Theme: `--hawk-slot-accent`, `--hawk-slot-text`,
194
+ `--hawk-slot-muted`, `--hawk-slot-border`, `--hawk-slot-surface`, `--hawk-slot-font`, `--hawk-slot-radius`.
195
+ - **The host backend adds the client, engine and editor** from its session: never from the browser.
196
+
197
+ ### Hosts: `<hawk-app-icon>`
198
+
199
+ An app's icon, from the registry's `iconUrl` and `iconKind` (registered by `defineHawkAppIcon()`, and by
200
+ `defineHawkAppFrame()`, `defineHawkAppContainer()` and `defineHawkAppSlot()`):
201
+
202
+ ```html
203
+ <hawk-app-icon src="https://apps.example.com/price-watch/v3/icon.svg" kind="svg"></hawk-app-icon>
204
+ ```
205
+
206
+ - **Attributes:** `src`, `kind` (`svg` or `png`), `label` (the accessible name; without it the icon is decorative).
207
+ - **`svg` is drawn in the host's color:** only its shape counts, filled with `currentColor` (a CSS mask), like the
208
+ host's own icons in light and dark themes. **`png` is shown as is.**
209
+ - **No `src`, or one that fails to load:** a built-in default app icon, in `currentColor`.
210
+ - **Size and color:** it fills its own box, 24 × 24 px by default: set `width`, `height` and `color` on it. The
211
+ `state` attribute (`svg`, `png`, `default`) says what it shows.
212
+
213
+ ## Data
214
+
215
+ Every call goes through the Hawk Apps Platform with a short-lived app token. The platform adds the credentials
216
+ server-side and takes the client from the signed token, so an app can only ever read the current client's data. Ask
217
+ for the scopes you need in `hawk-app.json`.
218
+
219
+ ### Analytics reports: `reports:read`
220
+
221
+ ```ts
222
+ const top = await app.analytics.report('queries', { limit: 20 });
223
+ // { columns: [{ name, type }], data: [...rows], totalRecords, page, hasMore }
224
+
225
+ const lastWeek = await app.analytics.report('searches', { startDate: '2026-09-24', endDate: '2026-09-30' });
226
+ ```
227
+
228
+ Dates default to the host's date range. Request options: `startDate`, `endDate`, `limit` (default 100), `page`,
229
+ `filters` and `having` (`{ member, operator, values }`), as in the
230
+ [HawkSearch Analytics API](https://developers.analytics.hawksearch.com/), which documents each report's columns.
231
+ Data refreshes nightly. A third argument takes `{ signal?, cache? }`; in a caching view, pass `{ cache: 'no-store' }`
232
+ for a report that records something (see [Views](#views-an-app-inside-other-pages)).
233
+
234
+ **Typed reports.** The bridge knows every report in the analytics API's spec: `report('searches')` returns typed rows,
235
+ with numbers and booleans converted from the strings the API sends (`row.search_count` is a number), and `filters`
236
+ and `having` take only that report's fields. A renamed or removed column fails your build once you update the bridge,
237
+ instead of showing blanks. `AnalyticsReportRow<'searches'>` is a row's type. The writes are typed too:
238
+ `report('ai-advisor-feedback', { recommendationId, engineId, feedback })` returns `{ feedback }`.
239
+
240
+ ```ts
241
+ const { data } = await app.analytics.report('assistant-activity', {
242
+ filters: [{ member: 'model_id', operator: 'equals', values: ['…'] }], // only this report's fields
243
+ });
244
+ const tokens = data.reduce((sum, row) => sum + row.total_tokens, 0); // numbers, not "123"
245
+ ```
246
+
247
+ Every report the analytics API has can be called, by its path without the slash (`assistant-prompts`); new ones need
248
+ no bridge or platform change: a report newer than your bridge returns untyped rows (`report<MyRow>('new-report')`),
249
+ with values as the API sends them. Passing your own row type to a known report (`report<MyRow>('searches')`) opts out
250
+ of its types, though its values are still converted. `report('', { query: '…' })` is the API's ad-hoc / natural-language query. For the list,
251
+ each report's columns and its `filters`/`having` fields, see the
252
+ [developer portal](https://developers.analytics.hawksearch.com/) (the dev platform uses the dev analytics API:
253
+ [its spec](https://developers.analytics.hawksearch.com/dev/openapi.yaml)), or ask your assistant: the `hawk-apps` MCP
254
+ server's `analytics_reports` tool reads that spec.
255
+
256
+ ### HawkSearch dashboard API: `hawksearch:read`
257
+
258
+ ```ts
259
+ const synonyms = await app.dashboard.get('Synonym');
260
+ const fields = await app.dashboard.get('Field');
261
+ // Query parameters: app.dashboard.get('LandingPage', { query: { ... } })
262
+ ```
263
+
264
+ Any resource of the [HawkSearch Dashboard API](https://developerdocs.hawksearch.com/reference/getting-started-with-your-api):
265
+ `Synonym`, `LandingPage`, `Field`, …. Leave the API version out; the platform picks it. Responses are HawkSearch's
266
+ body as is. To change data, see [Writing to HawkSearch](#writing-to-hawksearch-hawksearchwrite). For reporting, use
267
+ analytics. Your assistant can look up any operation's fields, its bridge call and scope with the `hawk-apps` MCP
268
+ server's `hawksearch_api` tool.
269
+
270
+ ### Writing to HawkSearch: `hawksearch:write`
271
+
272
+ ```ts
273
+ // Create a synonym
274
+ const synonym = await app.dashboard.post('Synonym', {
275
+ SearchTerm: 'sneakers',
276
+ SynonymGroup: 'trainers,running shoes',
277
+ MultiWay: true,
278
+ });
279
+
280
+ // Update a redirect
281
+ await app.dashboard.put('Redirect/42', { ...redirect, NewUrl: '/sale' });
282
+
283
+ // Delete a banner
284
+ await app.dashboard.delete('Banner/7');
285
+ ```
286
+
287
+ `app.dashboard.post(path, body?)`, `put(path, body?)` and `delete(path)` (also on `ctx.platform.dashboard` in
288
+ [functions](#functions-functionsinvoke)) send the body as JSON to the
289
+ [HawkSearch Dashboard API](https://developerdocs.hawksearch.com/reference/getting-started-with-your-api) and resolve
290
+ to HawkSearch's response body (`undefined` when it's empty). Paths work as for `get`: no API version (`Synonym`,
291
+ `Synonym/123`, `LandingPage/bulk`); each segment is URL-encoded for you.
292
+
293
+ - **Scope**: add `"hawksearch:write"` to `scopes` in `hawk-app.json` (reviewed like any scope). Reading stays under
294
+ `hawksearch:read`; ask for both if you read what you write.
295
+ - **Allowed resources**: `Banner`, `BoostBury`, `Campaign`, `Visibility`, `VisitorTarget`, `Redirect`, `Synonym`,
296
+ `SpellCheck`, `PaginationSet`, `SortingSet`, `RecommendationVisibility`, `LandingPageLayout`, `Facet` and
297
+ `LandingPage` (and their sub-paths). Anything else, e.g. `Field`, fails with 403 `path_not_allowed`; so do bulk
298
+ landing-page deletes.
299
+ - **Limits**: bodies up to 5 MB, bulk requests up to 1000 items, and 120 writes per minute per app and client.
300
+ - **Site keys only**: writes go through a site's own HawkSearch API key. When the client is connected with a group
301
+ key, writes fail with 403 `hawksearch_write_needs_site_key` (reads still work).
302
+ - **Audited**: every write is audit-logged by the platform (which app, for which client, by which user).
303
+ - **Not retried**: writes aren't idempotent, so the bridge sends each one once. A 429 `too_many_requests` is thrown
304
+ with `error.retryAfterMs`; wait at least that long before you try again, and only if repeating the write is safe.
305
+
306
+ | Code | Status | Meaning |
307
+ |---|---|---|
308
+ | `scope_missing` | 403 | The app doesn't have the `hawksearch:write` scope. |
309
+ | `path_not_allowed` | 403 | The resource isn't one of the allowed ones above (or it's a bulk landing-page delete). |
310
+ | `hawksearch_write_needs_site_key` | 403 | The client is connected with a group key; writes need a site key. |
311
+ | `request_too_large` | 413 | The body is over 5 MB. |
312
+ | `too_many_items` | 400 | A bulk request has more than 1000 items. |
313
+ | `too_many_requests` | 429 | Over 120 writes a minute for this app and client; `retryAfterMs` says when to try again. Not retried. |
314
+ | `invalid_path` | 400 | The path is malformed (the bridge also rejects empty paths and `.`/`..` segments with a `TypeError`). |
315
+ | `version_not_allowed` | 400 | You put an API version in the path: use `Synonym`, not `v11/Synonym`. |
316
+ | `hawksearch_unavailable` | 502 | HawkSearch couldn't be reached: the write may or may not have happened, so check before you repeat it. |
317
+ | `hawksearch_credentials_rejected` | 502 | HawkSearch rejected the client's API key: tell Engineering. |
318
+
319
+ HawkSearch's own validation errors (e.g. 400 for a missing required field, 404 for an unknown id) come back as
320
+ `HawkApiError` with HawkSearch's status and its response in `error.body`.
321
+
322
+ ### Custom data (queries): `data:read`
323
+
324
+ When the reports don't have what you need, ship your own SQL with the app. Each query is two files in `queries/`,
325
+ next to `hawk-app.json` (not in the build folder); `hawk-app deploy` checks them and deploys them with the version,
326
+ and Bridgeline staff see them when they review it. The platform runs them (Amazon Athena) against the **current
327
+ client's** analytics tables only: the client comes from the app token, never from the app.
328
+
329
+ ```sql
330
+ -- queries/top-searches.sql: one SELECT (or WITH … SELECT), plain table names, ? for each parameter in order
331
+ SELECT search_date, sum(search_count) AS searches
332
+ FROM dailysearches
333
+ WHERE search_date BETWEEN ? AND ?
334
+ AND {{partitions(from, to)}}
335
+ GROUP BY 1
336
+ ORDER BY 1
337
+ ```
338
+
339
+ ```json
340
+ {
341
+ "params": [
342
+ { "name": "from", "type": "date", "required": true },
343
+ { "name": "to", "type": "date", "required": true }
344
+ ]
345
+ }
346
+ ```
347
+
348
+ (`queries/top-searches.json`. `from` and `to` bind the two `?`s, in order.)
349
+
350
+ ```ts
351
+ // `date` params are "YYYY-MM-DD"; the host's date range is full ISO timestamps, so cut them to the day.
352
+ const day = (iso: string) => iso.slice(0, 10);
353
+ const { from, to } = app.context.dateRange!;
354
+
355
+ const { columns, rows, bytesScanned, cached } = await app.data.query<{ search_date: string; searches: number }>('top-searches', {
356
+ from: day(from),
357
+ to: day(to),
358
+ });
359
+ // rows: [{ search_date: '2026-09-01', searches: 1204 }, ...]
360
+ ```
361
+
362
+ `app.data.query(name, params?, { signal?, timeoutMs?, cache? })` resolves to `{ columns: [{ name, type }], rows, rowCount,
363
+ bytesScanned, cached, cachedAt }` (`cachedAt`: when the platform's view cache stored the rows, else null; `cache`:
364
+ `'reload'` or `'no-store'`, see [Views](#views-an-app-inside-other-pages)). Rows are objects keyed by column name, typed: numbers for integer/decimal/double columns,
365
+ booleans, ISO strings for dates and timestamps, `null` for nulls. Long queries are polled for you (1 s, 2 s, 4 s, then
366
+ every 5 s) until they finish or `timeoutMs` (default 120 s) passes; pass an `AbortSignal` to stop waiting (the
367
+ promise rejects with an `AbortError`). When too many queries are running (429 `too_many_queries`) the call waits and
368
+ tries again (1 s, 2 s, 4 s, then every 5 s, never sooner than the platform's `Retry-After`), until `timeoutMs`; only
369
+ then does it reject with the 429. `cached` is `true` when the result wasn't computed for this call: Athena reused a
370
+ recent result of the same query and parameters, or an identical request made moments earlier was already running it.
371
+
372
+ **Parameters** are declared in `<name>.json`, in the order of the `?`s in the SQL, and are always sent as query
373
+ parameters (never pasted into the SQL):
374
+
375
+ | Type | Value |
376
+ |---|---|
377
+ | `string` | up to 1000 characters |
378
+ | `int`, `decimal` | a number; `min` / `max` optional |
379
+ | `date` | `"YYYY-MM-DD"` |
380
+ | `timestamp` | ISO 8601, e.g. `"2026-09-01T00:00:00Z"` |
381
+ | `boolean` | `true` / `false` |
382
+ | `string[]` | up to 100 strings (`maxItems` to lower it); counts as **one** `?`, written `IN ?` (it becomes `IN (?, ?, …)`) |
383
+
384
+ Each param has a `name` and `type`, and optionally `required`, `default`, `min`/`max` (numbers) and `maxItems`
385
+ (`string[]`). The number of `?`s must equal the number of params.
386
+
387
+ **`{{partitions(from, to)}}`** limits the scan to the months between two `date` params (both must be declared
388
+ `date`; they still bind their own `?`s). Use it on every query with a date range: most tables are partitioned by
389
+ year/month, so without it a query reads the client's whole history. Add the table alias as a third argument when the
390
+ query joins tables: `{{partitions(from, to, d)}}`. On a table that isn't partitioned it becomes `TRUE`.
391
+
392
+ **Tables:** `apiusage`, `dailysearches`, `dailyusers`, `dailyrequests`, `geodistribution`, `redirecttracking`,
393
+ `productimpressions`, `searchfunnel`, `pageloads`, `analyticsapilogs`, `queries`, `clickpositions`, `facets`,
394
+ `sortingtracking`, `paginationtracking`, `poorresults`, `dailyspelling`, `salesbydate`, `salesbykeyword`,
395
+ `salesbyproduct`, `recommendations`, `campaigns`, `banners`, `autocompleteclicks`, `autocompletetrendingcategories`,
396
+ `autocompletetrendingitems`, `productratings`, `devicebrowser`, `landingpages`, `gacore`, `catalog`. Any other table
397
+ fails with `TABLE_NOT_FOUND`.
398
+
399
+ **Sensitive tables** hold visitor-level data or conversations: `summary`, `agentprompts`, `agentexchanges`,
400
+ `agenttoolcalls`, `aisummary`, `facetselections`, `visitortargetselections`. An app can query them only after
401
+ Engineering turns on **Allow sensitive data** for it (in the analytics portal; it applies to every client the app runs
402
+ for). Until then they also fail with `TABLE_NOT_FOUND`. Ask Engineering when your app needs one, and select only the
403
+ columns you need (counts rather than raw text where you can). `npx hawk-app data tables` and `npx hawk-app data describe <table>` show the columns, and
404
+ `npx hawk-app data run queries/top-searches.sql --client <id> --param from=2026-09-01 --param to=2026-09-30` tries a
405
+ query on dev before you deploy it.
406
+
407
+ **Limits:** one statement per file, `SELECT`/`WITH` only, 64 KB per query, 50 queries per app, 10,000 rows / 5 MB per
408
+ result, and a per-query scan limit (10 GB by default). Results of the same query with the same parameters are reused
409
+ for up to 15 minutes (`cached: true`). Data refreshes nightly.
410
+
411
+ **Errors** (`HawkApiError.code`):
412
+
413
+ | Code | Status | Meaning |
414
+ |---|---|---|
415
+ | `query_not_found` | 404 | No `queries/<name>.sql` in the version the user has open. |
416
+ | `invalid_params` | 400 | A parameter is missing or has the wrong type; `JSON.parse(error.body).errors` lists them. |
417
+ | `query_failed` | 400 | Athena rejected or failed the query (the message says why, e.g. an unknown column). |
418
+ | `scan_limit_exceeded` | 400 | The query reads too much data: narrow the date range or use `{{partitions(…)}}`. |
419
+ | `result_too_large` | 413 | More than 10,000 rows or 5 MB: aggregate, filter or add `LIMIT`. |
420
+ | `too_many_queries` | 429 | Too many queries running (at most 2 per app and client at a time): `app.data.query` already retried until `timeoutMs`. |
421
+ | `query_timeout` | 408 | Still running after `timeoutMs` (raised by the bridge). |
422
+ | `data_not_available` | 404 | Analytics data isn't set up for this client (when `context.tenant.analyticsProvisioned` is `false` it answers at once, without running the query). |
423
+ | `data_unavailable` | 502 | Something is wrong on our side: retry later. |
424
+
425
+ **Writing good queries**
426
+ - Filter by date and add `{{partitions(from, to)}}`: it's what keeps queries fast and cheap.
427
+ - Aggregate in SQL (`GROUP BY`, `sum`, `count`) and return only what the screen shows; add `LIMIT` to lists.
428
+ - Name the columns you need instead of `SELECT *`: Athena reads only those columns.
429
+ - Column names come back exactly as written; give computed columns an alias (`sum(x) AS searches`).
430
+ - Run independent queries in parallel (`Promise.all`), but no more than two at a time per client: more just wait
431
+ their turn.
432
+ - Show something while waiting: a first run can take a few seconds; repeats within 15 minutes are instant.
433
+
434
+ ### Who am I
435
+
436
+ ```ts
437
+ const me = await app.api.get('me'); // { app, clientId, accountId, userId, scopes, internal }
438
+ // internal: true for Bridgeline staff (from the signed token), e.g. to show a staff-only tab
439
+ ```
440
+
441
+ ## Functions: `functions:invoke`
442
+
443
+ Server-side code that ships with your app: call a third-party API with a key the browser must never see, combine
444
+ several platform calls, or reshape data before it reaches the app. Each function is a file in `functions/`, deployed
445
+ with every version as an AWS Lambda function (Node 22) and called from the app with `app.functions.call`.
446
+
447
+ **Engineering must enable functions for your app** (they reach the internet and can hold secrets). Until then calls
448
+ fail with `functions_not_enabled`. A version whose functions changed (added, removed, new code or settings) **always
449
+ needs review**, even on an auto-approve app.
450
+
451
+ ```
452
+ my-app/
453
+ hawk-app.json "scopes": ["functions:invoke", …],
454
+ "functions": { "sync-feed": { "timeoutSeconds": 20, "memoryMb": 512, "secrets": ["FEED_API_KEY"] } }
455
+ functions/
456
+ sync-feed.ts one entry file per function (.ts, .mts, .js or .mjs), named after it, exporting `handler`
457
+ lib/feed.ts shared code goes in subfolders (or import from src/ and node_modules)
458
+ ```
459
+
460
+ ```ts
461
+ // functions/sync-feed.ts
462
+ import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
463
+
464
+ export const handler: HawkFunction<{ since: string }, { items: unknown[] }> = async (input, ctx) => {
465
+ const response = await fetch(`https://feeds.example.com/items?since=${input.since}`, {
466
+ headers: { Authorization: `Bearer ${ctx.secrets.FEED_API_KEY}` },
467
+ });
468
+ if (!response.ok) throw new Error(`Feed answered ${response.status}`);
469
+ const top = await ctx.platform.analytics.report('searches', { startDate: input.since, limit: 10 });
470
+ return { items: [...(await response.json()), ...top.data] };
471
+ };
472
+ ```
473
+
474
+ ```ts
475
+ // In the app
476
+ const { items } = await app.functions.call<{ items: unknown[] }>('sync-feed', { since: '2026-09-01' });
477
+ ```
478
+
479
+ `app.functions.call(name, input?, { signal?, timeoutMs? })` posts `input` (any JSON, up to 256 KB) and resolves to
480
+ what `handler` returned (up to 6 MB). It waits up to `timeoutMs` (default 30 s) and retries while the platform answers
481
+ 429 (too many calls running), like data queries.
482
+
483
+ **`ctx`** (`HawkFunctionContext`)
484
+
485
+ | | |
486
+ |---|---|
487
+ | `ctx.client.id` | The HawkSearch client the app is running for. |
488
+ | `ctx.user` | `{ id, email, roles }` of the caller; `ctx.internal` is true for Bridgeline staff. |
489
+ | `ctx.app.slug`, `ctx.version` | Your app and the version the caller has open (the function code is that version's). |
490
+ | `ctx.secrets.NAME` | The secrets the function declares in `hawk-app.json` (see below). Nothing else. |
491
+ | `ctx.config` | The app's [config](#app-config) for the caller's client and this version, already resolved: `{ key: value }` for every declared key (null when nothing is set and there's no default). Frozen. |
492
+ | `ctx.platform` | The platform API **as the caller**: `analytics.report`, `dashboard.get/post/put/delete`, `data.query` (polled for you), [`email.send`](#email-emailsend), [`hawksearch.search/autocomplete`](#hawksearch-search-hawksearchsearch), [`hawksearch.systemParameters`](#hawksearch-system-parameters-hawksearchsysparamswrite), [`functions.start/job/wait`](#background-jobs), [`ai.generate/stream/info`](#ai-aiinvoke) and `api.get/post`, with your app's scopes. Analytics dates don't default on the server: pass them. |
493
+ | `ctx.job`, `ctx.trigger` | For a [background job](#background-jobs): `{ id, progress(percent?, message?) }` (null for a call), and what started the run (`call` or `schedule`). |
494
+ | `ctx.aws` | Only with `data:read`: short-lived credentials scoped to this client's analytics data, plus `region`, `database`, `workGroup`, `outputLocation`, for the AWS SDK's Athena client. Prefer `ctx.platform.data.query` when an app query does the job. |
495
+
496
+ The deploy wraps your `handler` with `toLambdaHandler` from `@bridgeline-digital/hawk-app-bridge/server` and
497
+ bundles it with every dependency, so the app needs this package installed (0.4.0 or later). The `/server` entry has
498
+ no DOM; `createPlatformClient({ apiBaseUrl, token })` gives you the same `ctx.platform` in tests or scripts.
499
+
500
+ **Settings** (per function in `hawk-app.json`; all optional): `job` (a [background job](#background-jobs), default
501
+ false), `timeoutSeconds` 1–25 (default 10; 1–900 for a job, default 300), `memoryMb` 128–1024
502
+ (default 256; more memory also means more CPU), `secrets` up to 20 names. At most 10 functions per app; names are
503
+ lowercase letters, digits and dashes, up to 40 characters. Bundles are limited to 10 MB zipped, the `functions/`
504
+ sources to 5 MB.
505
+
506
+ **Secrets** are set with the CLI, never in code or in the app: `npx hawk-app secrets set FEED_API_KEY` (input hidden;
507
+ `--value-from-env VAR` in CI), at most 4 KB each; the platform stores them encrypted. They belong to the app, not a
508
+ version: a change applies to every version at once. A function receives only the secrets it declares; a declared secret
509
+ that isn't set fails the call with `secret_missing`.
510
+
511
+ **Client secrets** hold values that belong to each client, such as their own email platform's API key. Declare the
512
+ names on the function (`"clientSecrets": ["MAILCHIMP_API_KEY"]`, up to 10). The client's users set them in the app,
513
+ the function reads the calling client's value, and nobody can read a value back:
514
+
515
+ ```ts
516
+ const list = await app.clientSecrets.list(); // [{ name, set, updatedAt }]
517
+ await app.clientSecrets.set('MAILCHIMP_API_KEY', key); // write-only, at most 4 KB
518
+ await app.clientSecrets.delete('MAILCHIMP_API_KEY');
519
+ // In the function: ctx.clientSecrets.MAILCHIMP_API_KEY (missing when the client hasn't set it).
520
+ ```
521
+
522
+ **Logs**: `console.log` / `console.error` in a function go to its log, kept 30 days:
523
+ `npx hawk-app logs sync-feed --since 1h --follow`. Never log secrets, tokens or personal data.
524
+
525
+ **Errors** come back to the app as `HawkApiError`:
526
+
527
+ | Code | Status | Meaning |
528
+ |---|---|---|
529
+ | `function_failed` | 502 | `handler` threw. The response has only the error's name (`errorType`); the message and stack are in the logs. |
530
+ | `function_timeout` | 504 / 408 | The function ran past its `timeoutSeconds` (504), or the call past `timeoutMs` (408, raised by the bridge). |
531
+ | `result_too_large` | 502 | The result is over 6 MB. |
532
+ | `request_too_large` | 413 | The input is over 256 KB. |
533
+ | `too_many_requests` | 429 | Too many calls running at once: `app.functions.call` already retried until `timeoutMs`. |
534
+ | `function_not_found` | 404 | The version the caller has open has no such function (deploy it first). |
535
+ | `function_is_job` / `function_not_job` | 409 | `call` on a job function (use `start`), or `start` on a normal one. |
536
+ | `functions_not_enabled` | 403 | Engineering hasn't enabled functions for the app. |
537
+ | `secret_missing` | 500 | A declared secret isn't set: `npx hawk-app secrets set <NAME>`. |
538
+
539
+ Good to know: a function runs **as the caller**, for the caller's client, so scope anything you store by
540
+ `ctx.client.id`. Functions are stateless and short (25 s at most, or 900 s for a job); keep nothing between calls
541
+ that you can't lose.
542
+
543
+ ### Background jobs
544
+
545
+ Work that takes longer than a call (a full export, a batch of dashboard writes, a nightly report) goes in a **job
546
+ function**: `"job": true` in `hawk-app.json`. The app starts it, gets a job id at once, and reads the result later.
547
+
548
+ ```jsonc
549
+ "functions": {
550
+ "export-report": { "job": true, "timeoutSeconds": 600 }
551
+ }
552
+ ```
553
+
554
+ ```ts
555
+ // functions/export-report.ts
556
+ export const handler: HawkFunction<{ month: string }, { rows: number }> = async (input, ctx) => {
557
+ const pages = await loadPages(input.month);
558
+ for (const [i, page] of pages.entries()) {
559
+ await writePage(page);
560
+ await ctx.job?.progress(Math.round(((i + 1) / pages.length) * 100), `Page ${i + 1} of ${pages.length}`);
561
+ }
562
+ return { rows: pages.length };
563
+ };
564
+ ```
565
+
566
+ ```ts
567
+ // In the app
568
+ const { jobId } = await app.functions.start('export-report', { month: '2026-09' });
569
+ try {
570
+ const job = await app.functions.wait<{ rows: number }>(jobId, {
571
+ onProgress: (j) => setStatus(j.progress?.message ?? j.status),
572
+ });
573
+ console.log(job.result!.rows);
574
+ } catch (error) {
575
+ if (error instanceof FunctionJobError) console.log(error.job.status, error.job.error?.type); // failed / timed_out
576
+ }
577
+ ```
578
+
579
+ - `app.functions.start(name, input?, { signal? })` resolves to `{ jobId }` once the job is queued (202). A job
580
+ function can only be started and a normal function only called (409 `function_is_job` / `function_not_job`).
581
+ - `app.functions.wait(jobId, { timeoutMs?, intervalMs?, onProgress?, signal? })` checks the job every `intervalMs`
582
+ (default 2 s) and resolves with the `succeeded` job; it throws `FunctionJobError` (with `.job`) when the job
583
+ `failed` or `timed_out`, and HawkApiError 408 `job_wait_timeout` after `timeoutMs` (default 15 min; the job keeps
584
+ running). `onProgress` gets the job whenever its status or progress changes.
585
+ - `app.functions.job(jobId)` reads it once: `{ jobId, function, status, progress, result, error, createdAt,
586
+ finishedAt, trigger }`, `status` one of `queued`, `running`, `succeeded`, `failed`, `timed_out`. Store the id to
587
+ pick the job up again after a reload.
588
+ - In the function, `ctx.job` is `{ id, progress(percent?, message?) }` (null for a call). `progress` sends at most
589
+ once a second (calls in between are merged) and never throws. `ctx.trigger` says what started the run:
590
+ `{ type: 'call' }` or `{ type: 'schedule', schedule, enabledBy }`.
591
+ - `ctx.platform.functions.start/job/wait` start other jobs from a function, e.g. to fan work out.
592
+
593
+ Limits: a job runs up to 900 s (`timeoutSeconds`, default 300); 3 jobs per app and client run at once (429
594
+ `too_many_jobs`, not retried); results up to 256 KB; jobs are kept 7 days (then 404 `job_not_found`, also for
595
+ another app's or client's job). A job isn't retried when it fails.
596
+
597
+ ### Schedules
598
+
599
+ `schedules` in `hawk-app.json` run a job function on a timer. A schedule does nothing until a user turns it on from
600
+ the app, for their client: `app.schedules.enable(name)`.
601
+
602
+ ```jsonc
603
+ "functions": { "nightly-report": { "job": true, "timeoutSeconds": 600 } },
604
+ "schedules": {
605
+ "nightly": { "function": "nightly-report", "cron": "0 7 ? * MON-FRI *", "timezone": "America/Chicago" }
606
+ }
607
+ ```
608
+
609
+ ```ts
610
+ const schedules = await app.schedules.list();
611
+ // [{ name: 'nightly', function: 'nightly-report', cron: '0 7 ? * MON-FRI *', timezone: 'America/Chicago',
612
+ // enabled: false, enabledBy: null, enabledAt: null, lastRun: null }]
613
+ await app.schedules.enable('nightly'); // or disable('nightly'); both resolve to the schedule
614
+ ```
615
+
616
+ - `cron` is an EventBridge cron with 6 fields (`minutes hours day-of-month month day-of-week year`, with `?` in
617
+ day-of-month or day-of-week) and runs **at most hourly** (the minutes field is a single number); `timezone` is an
618
+ IANA name, default `UTC`. At most 5 schedules per app, each running a job function of the same manifest.
619
+ - A scheduled run is a job (`ctx.trigger.type === 'schedule'`) for the client and site where the user turned the
620
+ schedule on, as the user `schedule:<name>` (no roles, not internal), with that client's HawkSearch connection. Its
621
+ `lastRun` shows the job it started, or `skipped` with a `reason`.
622
+ - Schedules are a setting reviewed like scopes. They stop when the app's release is withdrawn, functions are turned off for
623
+ it, or a version without the schedule is approved.
624
+
625
+ ## Email: `email:send`
626
+
627
+ Send an email from the app or from a [function](#functions-functionsinvoke): a report to the user, an alert, a
628
+ digest. The platform sends it; your app never gets mail credentials.
629
+
630
+ Add `"email:send"` to `scopes` in `hawk-app.json` and deploy (`hawk-app deploy` sends hawk-app.json with the version): any app with the scope can send. Without it, sends fail
631
+ with `scope_missing`.
632
+
633
+ ```ts
634
+ // functions/weekly-digest.ts: from a function, as the caller
635
+ import type { HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
636
+
637
+ export const handler: HawkFunction<void, { messageId: string }> = async (_input, ctx) => {
638
+ if (!ctx.user.email) throw new Error('The caller has no email address.');
639
+ const top = await ctx.platform.analytics.report('searches', { startDate: '2026-09-01', endDate: '2026-09-30', limit: 5 });
640
+ const { messageId } = await ctx.platform.email.send({
641
+ to: ctx.user.email,
642
+ subject: 'Your top searches',
643
+ text: top.data.map((row) => JSON.stringify(row)).join('
644
+ '),
645
+ html: `<p>${top.data.length} rows</p>`,
646
+ });
647
+ return { messageId };
648
+ };
649
+ ```
650
+
651
+ ```ts
652
+ // Or straight from the app
653
+ const { messageId, recipients } = await app.email.send({ to: ['a@example.com', 'b@example.com'], subject: 'Export ready', text: 'Your export is ready.' });
654
+ ```
655
+
656
+ `app.email.send({ to, subject, text?, html?, replyTo? }, { signal? })` (and `ctx.platform.email.send`, the same)
657
+ resolves to `{ messageId, recipients }`.
658
+
659
+ - **Sender**: `"<App name> (HawkSearch)" <noreply@hawksearch.com>`, with your app's name. Nobody reads replies to it:
660
+ set `replyTo` (one address) if recipients should be able to answer.
661
+ - **Recipients**: `to` is one address or up to 10 unique ones, plain addresses only (no display names, no cc/bcc).
662
+ Apps can email anyone, so only send what the recipient should see, and never secrets.
663
+ - **Content**: `subject` 1–200 characters on one line; at least one of `text` / `html`, together at most 256 KB.
664
+ - **Limit**: 200 recipients per app and client per UTC day (a 10-recipient send counts 10), for every version of the
665
+ app together. Over it, sends fail with `email_limit_reached` until UTC midnight (`error.retryAfterMs`). Sends are
666
+ never retried for you.
667
+ - **Audit**: every send is logged with the app, version, client, user, recipient count and domains and the message
668
+ id. The subject and body are never stored.
669
+
670
+ **Errors** come back as `HawkApiError`:
671
+
672
+ | Code | Status | Meaning |
673
+ |---|---|---|
674
+ | `scope_missing` | 403 | The app doesn't have the `email:send` scope. |
675
+ | `invalid_email` | 400 | A field is missing or wrong; the response's `errors` (in `error.body`) lists each one. |
676
+ | `request_too_large` | 413 | The body is over 256 KB. |
677
+ | `email_limit_reached` | 429 | The app's daily limit for this client is used up; `retryAfterMs` is the time until it resets. |
678
+ | `email_rejected` | 400 | The mail service refused the message (e.g. an address it can't deliver to). |
679
+ | `email_unavailable` | 502 | The mail service failed: try again later. |
680
+
681
+ ## HawkSearch search: `hawksearch:search`
682
+
683
+ Run real HawkSearch searches and autocompletes for the engine the user is working on, live or in **preview**
684
+ (unpublished rules, a future date): before/after checks of a rule change, "do our top queries return results?",
685
+ zero-result hunts. From the app or from a [function](#functions-functionsinvoke) (`ctx.platform.hawksearch`).
686
+
687
+ ```ts
688
+ const results = await app.hawksearch.search({ Keyword: 'boots', MaxPerPage: 24 });
689
+ results.Pagination?.NofResults; // HawkSearch's own v2 response
690
+
691
+ // Preview: unpublished rules as of a date (the engine's time zone)
692
+ await app.hawksearch.search({ Keyword: 'boots', IsInPreview: true, SmartBar: { PreviewDate: '2026-11-27T00:00:00' } });
693
+
694
+ await app.hawksearch.autocomplete({ Keyword: 'boo', ProductCount: 5 });
695
+ ```
696
+
697
+ - The request is HawkSearch's v2 search / autocomplete body, sent as is: any field works (`Keyword`, `PageNo`,
698
+ `MaxPerPage`, `SortBy`, `FacetSelections`, `CustomUrl`, `IndexName`, `SmartBar`, …). The platform sets `ClientGuid`
699
+ to the current engine's; you never need it.
700
+ - `IsInPreview: true` runs on the engine's preview search API.
701
+ - At most 600 calls per app and client per minute: a `429 too_many_requests` carries `retryAfterMs` (not retried
702
+ for you). For bulk checks, run a few at a time.
703
+ - Read-only, so the scope doesn't stop an app from being auto-approved.
704
+
705
+ | `error.code` | Status | Meaning |
706
+ |---|---|---|
707
+ | `scope_missing` | 403 | Add `hawksearch:search` to `scopes`. |
708
+ | `engine_required` | 400 | The host session has no engine. |
709
+ | `search_not_available` | 404 / 503 | The engine isn't active, or has no search API set up in HawkSearch. |
710
+ | `search_needs_site_key` | 403 | The engine requires authenticated searches and the session only has a group API key. |
711
+ | `too_many_requests` | 429 | Over the per-minute limit; wait `retryAfterMs`. |
712
+ | `hawksearch_unavailable` | 502 | HawkSearch didn't answer: try again. |
713
+
714
+ Other HawkSearch errors (e.g. `400` for an unknown `IndexName`) come back as HawkSearch sent them.
715
+
716
+ ## HawkSearch system parameters: `hawksearch:sysparams:write`
717
+
718
+ Let users change a few HawkSearch **system parameters** of the engine they are working on (Sites → System Parameters
719
+ in HawkSearch) from the app or from a [function](#functions-functionsinvoke), e.g. a setup wizard that sets the time
720
+ zone and the preview URL.
721
+
722
+ Add the scope and list the parameters the app may touch in `hawk-app.json`:
723
+
724
+ ```json
725
+ {
726
+ "scopes": ["hawksearch:sysparams:write"],
727
+ "systemParameters": ["Timezone", "SearchPreviewURL"]
728
+ }
729
+ ```
730
+
731
+ The list is reviewed like scopes: it goes with the version, which waits for approval in HawkSearch (`hawk-app status`:
732
+ `settings waiting for review: systemParameters`), and only the approved list counts. Up to 50 names (letters, digits, `_`, `.`, `-`; up to 50
733
+ characters each), matched case-insensitively. The scope needs a non-empty list, and the list needs the scope.
734
+
735
+ ```ts
736
+ const { engineId, parameters } = await app.hawksearch.systemParameters.list();
737
+ // parameters: [{ name: 'Timezone', label: 'Time zone', group: 'General', type: 'TIMEZONE',
738
+ // value: 'Eastern Standard Time', encrypted: false, readOnly: false }, ...]
739
+
740
+ const timezone = await app.hawksearch.systemParameters.get('Timezone');
741
+
742
+ const { value, previous, appliesWithinSeconds } = await app.hawksearch.systemParameters.set('Timezone', 'UTC');
743
+ await app.hawksearch.systemParameters.set('SearchPreviewURL', null); // null or "" clears it
744
+ // A declared parameter the engine doesn't have yet: create it (AE only, label/type optional), then set it.
745
+ await app.hawksearch.systemParameters.set('packageTier', 'Premium', { create: { group: 'HawkAI Configuration', label: 'Package Tier' } });
746
+ ```
747
+
748
+ `ctx.platform.hawksearch.systemParameters` in a function is the same, as the caller.
749
+
750
+ - **Which engine**: always the user's current engine in the host (`context.engine`), sent by the host when it issues
751
+ the app token, never chosen by the app. After the user switches engines, the host issues tokens for the new engine.
752
+ Only that engine changes: it is not synchronized to the other engines of a group.
753
+ - **Who can set**: any user who can open the app. Every change is logged in HawkSearch (the parameter's history and
754
+ the activity log, as `<user> via Hawk Apps (<slug>)`) and in the platform's audit log.
755
+ - **Which parameters**: only the declared ones that exist for the engine, including "AE only" ones (shown in red in
756
+ HawkSearch). Encrypted, global and read-only parameters are never settable; `list` leaves out global ones and
757
+ returns encrypted ones with `value: null`.
758
+ - **Values** are strings, checked against the parameter's type: `STRING` (up to 1000 characters), `EMAIL`,
759
+ `INTEGER`, `FLOAT`, `YESNO` (`"0"` / `"1"`; `"true"` / `"false"` are accepted), `TIMEZONE` (a Windows time zone id,
760
+ e.g. `"Eastern Standard Time"`), `DIMENSION` (`"width;height"`). Other types can't be set yet. Setting the current
761
+ value again changes nothing.
762
+ - **When it applies**: HawkSearch caches system parameters, so a change applies within about **3 minutes**
763
+ (`appliesWithinSeconds`: 180). Calls are never retried for you.
764
+
765
+ **Errors** come back as `HawkApiError`:
766
+
767
+ | Code | Status | Meaning |
768
+ |---|---|---|
769
+ | `scope_missing` | 403 | The app doesn't have the `hawksearch:sysparams:write` scope. |
770
+ | `engine_required` | 400 | The host didn't send an engine with the token (no engine selected). |
771
+ | `engine_not_allowed` | 403 | The engine doesn't belong to the token's client. |
772
+ | `parameter_not_found` | 404 | Not declared in `systemParameters`, or the engine has no such parameter (also for global ones; AE-only parameters work when declared). |
773
+ | `parameter_read_only` | 403 | The parameter is read-only. |
774
+ | `parameter_encrypted` | 400 | Encrypted parameters can't be set. |
775
+ | `parameter_type_unsupported` | 400 | Parameters of this type can't be set yet. |
776
+ | `invalid_value` | 400 | The value doesn't fit the parameter's type (the message says why). |
777
+ | `hawksearch_db_unavailable` | 502 | HawkSearch couldn't be reached: try again later. |
778
+ | `sysparams_not_configured` | 503 | System parameters aren't enabled on this platform yet. |
779
+
780
+ ## HawkSearch features: `hawksearch:features:write`
781
+
782
+ Turn HawkSearch features on and off for the user's current engine: the Feature Configuration's additional features
783
+ (`aiassistant`, `didYouMean`, `sortingSets`, …) and facet features (`tooltip`, `swatch`, `collapsible`, …). Add
784
+ `"hawksearch:features:write"` to `scopes`; it's elevated (never auto-approved) and reaches every feature, paid add-ons
785
+ included.
786
+
787
+ ```ts
788
+ const { features, facetFeatures } = await app.hawksearch.features.list(); // [{ code, enabled, known }]
789
+ await app.hawksearch.features.set('aiassistant', true);
790
+ await app.hawksearch.features.setFacet('tooltip', false); // { code, enabled, known, changed, appliesWithinSeconds }
791
+ ```
792
+
793
+ Each call changes one code and keeps the others. Codes HawkSearch knows are stored in its spelling; any other code of
794
+ letters and digits is accepted (a feature HawkSearch adds later works at once) and answered with `known: false`.
795
+ Changes apply within about 3 minutes. Errors are those of system parameters, plus `invalid_feature` (400) for a code
796
+ that isn't 1-50 letters and digits.
797
+
798
+ ## HawkSearch index rebuilds: `hawksearch:index`
799
+
800
+ Rebuild the user's current engine's index from its feed, as HawkSearch's Rebuild Index button does, and follow it. Add
801
+ `"hawksearch:index"` to `scopes`; it's elevated (never auto-approved), since a rebuild reloads the client's live search.
802
+
803
+ ```ts
804
+ const run = await app.hawksearch.index.rebuild(); // { notificationId, status: 'new', done: false, … }
805
+ const final = await app.hawksearch.index.wait(run.notificationId!, {
806
+ onProgress: (r) => setStatus(`${r.status} (${r.count ?? 0} items)`),
807
+ }); // { status: 'success' | 'error' | …, done: true, count, durationSeconds }
808
+ const latest = await app.hawksearch.index.status(); // the engine's latest rebuild, by anyone
809
+ ```
810
+
811
+ - **Only where the engine allows it**: HawkSearch's `EnableRebuildIndex` system parameter must be on for the engine
812
+ (it's the switch for HawkSearch's own button). Otherwise 403 `index_rebuild_disabled`.
813
+ - **One at a time per engine**: while a rebuild is `new`, `pending`, `processing` or `queue` (from this app or anyone),
814
+ `rebuild()` answers 409 `index_in_progress` with the running one in `current`. A rebuild is done when its status is
815
+ `success`, `error`, `duplicated` or `suspended`.
816
+ - **Full rebuilds only**, from the engine's configured feed. There's no cancel, and progress is the status and item
817
+ count, not a percentage. Rebuilds take minutes: `wait` checks every 15 s by default, for up to an hour.
818
+ - **Site keys only**: with a group key, 403 `hawksearch_index_needs_site_key` (HawkSearch would rebuild the group's
819
+ parent site). At most 2 rebuild requests per app and client per minute (429 `too_many_requests`). Every rebuild is
820
+ audit-logged (app, client, engine, user).
821
+ - Other errors: `index_never_rebuilt` / `index_run_not_found` (404), `index_wait_timeout` (408, from `wait`),
822
+ `hawksearch_failed` / `hawksearch_unavailable` / `hawksearch_credentials_rejected` (502).
823
+
824
+ ### The Indexing API: `hawksearch:index`, `:index:items`, `:index:manage`
825
+
826
+ For engines with their own indexes (HawkSearch's Indexing API), apps can read, change and manage the indexes
827
+ themselves. Three scopes, all elevated, so an approver sees exactly what an app can do:
828
+
829
+ | Scope | Calls |
830
+ | --- | --- |
831
+ | `hawksearch:index` | `indexes()`, `current()`, and reads with `call`: `'inactive'`, `'hierarchy'`, `'mappings'`, `'embedding/progress'`, `` `embedding/status/${id}` `` |
832
+ | `hawksearch:index:items` | `items.upsert(items)`, `items.update(items)`, `items.delete(ids)`, and with `call`: `'add-attributes-to-items'`, `'update-attributes-of-items'`, `'remove-attributes-from-items'`, `'update-nested'`, `'remove-nested'`, `'bulk-update-nested'`, `'bulk-remove-nested'`, `'bulk-item-operations'` |
833
+ | `hawksearch:index:manage` | `create({ suffix })`, `setCurrent(name)`, `deleteIndex(name)`, `rebuildAll(name)`, and with `call`: `'swap'`, `'rebuild-autocomplete'`, `'rebuild-percolator'`, `'rebuild-learning'`, `'rebuild-related'`, `'rebuild-embedding'`, `'hierarchy/upsert'`, `'hierarchy/rebuild'`, `'hierarchy/delete-items'`, `'hierarchy/delete-all'`, `'mappings/upsert'`, `'mappings/invalidate-cache'` |
834
+
835
+ ```ts
836
+ // Update prices on the live index: each field is an array of values, with the engine's primary key.
837
+ const result = await app.hawksearch.index.items.update([{ id: ['sku-1'], price: [79.99] }]); // { Status, Summary, Items }
838
+
839
+ // Build a new index, fill it, then switch live search to it.
840
+ const name = await app.hawksearch.index.create({ suffix: 'spring' });
841
+ await app.hawksearch.index.items.upsert(batch, { indexName: name }); // up to 125 items a call
842
+ await app.hawksearch.index.rebuildAll(name);
843
+ await app.hawksearch.index.setCurrent(name);
844
+ ```
845
+
846
+ - **Live at once.** Item writes default to the engine's current index, and HawkSearch applies them immediately. Pass
847
+ `indexName` to change another one. HawkSearch checks each item against the engine's fields and primary key: a write
848
+ with any failed item is a 400 with HawkSearch's per-item `Status`.
849
+ - **Limits:** 125 items (or 1000 ids) per call, 5 MB; 120 item writes and 10 management calls per app and client per
850
+ minute (429 `too_many_requests`). Site keys only. Writes are audit-logged (operation, index, count), never retried.
851
+ - **Never the live index:** `deleteIndex` of the current index is 409 `index_is_current`. Engines may have only two
852
+ indexes: delete the old one before `create`. `unset-current`, index templates and group indexing aren't available.
853
+ - `call(operation, body?)` sends HawkSearch's own body
854
+ ([Indexing API](https://developerdocs.hawksearch.com/reference/indexv2_get)) and resolves to its answer. Engines
855
+ without an Indexing API answer 503 `indexing_api_not_available`; anything outside the list is 404.
856
+
857
+ ## HawkSearch recommendations: `hawksearch:recommendations`
858
+
859
+ HawkSearch's recommendation widgets for the user's current engine, as its storefront gets them. Add
860
+ `"hawksearch:recommendations"` to `scopes` (not elevated: read-only storefront results).
861
+
862
+ ```ts
863
+ const { widgetItems } = await app.hawksearch.recommendations({
864
+ widgetUids: [{ widgetGuid: '8a6d…', uniqueId: 'sku-1' }], // the widget, and the product being viewed (if its model needs one)
865
+ contextProperties: { uniqueidlist: 'sku-1,sku-2' }, // e.g. a cart
866
+ });
867
+ for (const item of widgetItems?.[0]?.recommendationItems ?? []) console.log(item.itemName, item.price);
868
+ ```
869
+
870
+ The request is HawkSearch's own ([Recommendation API](https://developerdocs.hawksearch.com/docs/recommendation-api)):
871
+ `widgetUids` for one context, or `widgets` (up to 10, each with its own `contextProperties`) for the bulk endpoint;
872
+ `renderHTML: true` for the widget's HTML; `enablePreview` for preview rules. The platform sets the engine's
873
+ `clientGuid`, adds a random `visitorId` and `visitId` when you send none (send the shopper's to personalize), and never
874
+ lets the request count in the client's recommendation reports. Widget guids are on HawkSearch's Recommendations page (or
875
+ `app.dashboard.get(...)` with `hawksearch:read`). Limited to 600 requests per app and client per minute (429
876
+ `too_many_requests`). Errors: `invalid_request` (400), `recommendations_not_configured` (503), `hawksearch_unavailable`
877
+ (502).
878
+
879
+ ## Storage: `storage`
880
+
881
+ Keep the app's own data on the platform: settings, state, history. Each item is a JSON value (at most 350 KB) under a
882
+ key, kept for the current client and shared by everyone at that client using the app, and by its functions and
883
+ schedules (`ctx.platform.storage`). Add `"storage"` to `scopes` (not elevated).
884
+
885
+ ```ts
886
+ const config = await app.storage.get<WatchConfig>('config'); // { key, value, version, ... } or null
887
+ await app.storage.put('config', { competitors }, { ifVersion: config?.version ?? 0 }); // 409 if someone changed it
888
+ await app.storage.put(`hist#${sku}#2026-10`, points, { ttlDays: 400 }); // expires in 400 days
889
+ const { items, cursor } = await app.storage.list({ prefix: 'match#', values: true });
890
+ await app.storage.delete('draft');
891
+ await app.storage.batch({ get: ['a', 'b'], put: [{ key: 'c', value: 1 }], delete: ['d'] }); // up to 25, each answered on its own
892
+ const { items: count, bytes, maxBytes } = await app.storage.usage();
893
+ ```
894
+
895
+ - **Keys:** 1–256 characters from `A–Z a–z 0–9 . _ - : / #`, not starting with `#`, and without empty, `.` or `..`
896
+ segments between slashes. Listing by prefix is the only query, so put what you look things up by first.
897
+ - **Limits:**
898
+ - 100 MB and 100,000 items per app and client: 507 `storage_quota_exceeded` beyond.
899
+ - 600 reads and 300 writes per app and client per minute: 429 `too_many_requests` with `retryAfterMs`.
900
+ - **Other errors:** 409 `version_conflict`, 413 `value_too_large`.
901
+ - **Reviewers' previews** use the client's real data, so version your keys (`v2#config`) when you change their shape.
902
+
903
+ **Files** (up to 100 MB each; 5 GB and 50,000 per app and client) go straight between the app and the platform's
904
+ bucket through short-lived signed requests:
905
+
906
+ ```ts
907
+ await app.storage.files.upload('exports/prices.csv', csv, { contentType: 'text/csv', ttlDays: 30 }); // Blob, bytes or text
908
+ const { url } = await app.storage.files.url('logos/acme.png'); // a 5-minute link: <img src={url}>, or { download: 'name' }
909
+ const blob = await app.storage.files.download('exports/prices.csv');
910
+ const { files, cursor } = await app.storage.files.list({ prefix: 'exports/' });
911
+ await app.storage.files.delete('exports/prices.csv');
912
+ ```
913
+
914
+ - **Paths:** segments of `A–Z a–z 0–9 . _ -` between slashes.
915
+ - **Expiry:** `ttlDays` is 1 to 365 (rounded up to 1, 7, 30, 90 or 365).
916
+ - **Errors:** 413 `file_too_large`, 507 `storage_quota_exceeded`, `upload_failed` when the bucket refuses the upload.
917
+ - **Usage:** `usage()` counts files a few seconds after they land.
918
+
919
+ See the platform's docs/storage.md.
920
+
921
+ ## External Lambdas: `lambdas:invoke`
922
+
923
+ Call a Lambda written outside Hawk Apps (for example HawkAnalytics' client setup) from the app or a function. Declare
924
+ it by name and add the scope; on a released app the change waits for approval, and Bridgeline Engineering allows each Lambda
925
+ on the platform.
926
+
927
+ ```json
928
+ { "scopes": ["lambdas:invoke"], "lambdas": ["analytics-client-setup"] }
929
+ ```
930
+
931
+ ```ts
932
+ const status = await app.lambdas.invoke('analytics-client-setup', { action: 'status' }); // or ctx.platform.lambdas
933
+ ```
934
+
935
+ The Lambda receives `{ caller, input }`: `caller` (client, user, engine, roles) comes from the app token, so it can't be
936
+ faked from the browser; `input` is what you pass. Calls are never retried. Errors: `lambda_not_allowed` (404),
937
+ `too_many_calls` (429, 30 a minute per app and client), `lambda_failed` / `lambda_unavailable` (502), `lambda_timeout`
938
+ (408, after `timeoutMs`, default 35 s). See the platform's docs/external-lambdas.md.
939
+
940
+ ## App config
941
+
942
+ Settings the app reads at runtime that someone changes without a deploy: who gets a notification email, a threshold, a
943
+ feature switch. Declare each key, its type and an optional default in `config` in hawk-app.json (the declarations are
944
+ reviewed like any setting); the values are set from the Hawk Analytics portal or with `npx hawk-app config set`, for the
945
+ whole app (global) or for one client, and apply within about 30 seconds, with no deploy or approval.
946
+
947
+ ```json
948
+ {
949
+ "config": {
950
+ "signupNotifyEmails": {
951
+ "type": "emails",
952
+ "label": "Sign-up notification recipients",
953
+ "description": "Who is emailed when a client signs up.",
954
+ "default": ["support@example.com"]
955
+ },
956
+ "connectorsRequestEmails": { "type": "emails", "label": "API Connections request recipients" }
957
+ }
958
+ }
959
+ ```
960
+
961
+ ```ts
962
+ const all = await app.config.all(); // { signupNotifyEmails: [...], connectorsRequestEmails: null }
963
+ const to = await app.config.get<string[]>('signupNotifyEmails'); // the value, or null
964
+ // In a function: ctx.config.signupNotifyEmails (already resolved), or ctx.platform.config.get(...)
965
+ ```
966
+
967
+ - **Not secret:** values reach the browser, so anyone using the app can see them. Keep API keys and passwords in
968
+ [secrets](#functions-functionsinvoke) (`hawk-app secrets`, or `app.clientSecrets` for each client's own).
969
+ - **Resolution:** the caller's client value, else the global value, else the declared `default`, else `null`. A stored
970
+ value that no longer matches the key's type is skipped. Every declared key is in `all()`; `get` returns `null` for a
971
+ key the app doesn't declare and throws a `TypeError` for an empty key.
972
+ - **Declarations:** at most 50 keys, names `^[A-Za-z][A-Za-z0-9_]{0,63}$`. Each has a `type` (`string`, `number`,
973
+ `boolean`, `emails`, `json`), an optional `label` (up to 100 characters), `description` (up to 500) and `default`.
974
+ - **Values by type:** `string` up to 4000 characters; `number` finite; `boolean`; `emails` a list of up to 50 distinct
975
+ plain addresses (`a@example.com`, no display names); `json` any JSON but `null`. At most 16 KB each.
976
+ - **No scope** is needed. Functions get the values for their own version, the app the ones for the version it has open.
977
+ - **Set values** with `npx hawk-app config set signupNotifyEmails a@example.com,b@example.com [--client e3141]`,
978
+ `config unset`, and `config list`, or in the Hawk Analytics portal.
979
+
980
+ | Code | Status | Meaning |
981
+ |---|---|---|
982
+ | `config_key_not_declared` | 404 | Setting a key that isn't in `config` in hawk-app.json. |
983
+ | `invalid_config_value` | 400 | The value doesn't match the key's type. |
984
+ | `invalid_client` | 400 | The client id isn't a valid client id. |
985
+ | `value_too_large` | 413 | The value is over 16 KB. |
986
+
987
+ ## AI: `ai:invoke`
988
+
989
+ Ask Claude from the app or from a [function](#functions-functionsinvoke) (`ctx.platform.ai`): summarize a report,
990
+ draft synonyms, explain a zero-result query. The platform calls the model; your app never holds a model key.
991
+
992
+ Add `"ai:invoke"` to `scopes` in `hawk-app.json` and deploy. It is an **elevated** scope: an app with
993
+ it is never auto-approved, and approvers review it like dashboard writes.
994
+
995
+ ```ts
996
+ // The whole answer at once
997
+ const { text, usage } = await app.ai.generate({ prompt: 'Suggest 5 synonyms for "sneakers", one per line.' });
998
+
999
+ // Or as it is written
1000
+ const stream = app.ai.stream({
1001
+ model: 'smart',
1002
+ system: 'You are a HawkSearch merchandising assistant. Answer in short bullet points.',
1003
+ messages: [{ role: 'user', content: `Explain these zero-result queries: ${queries.join(', ')}` }],
1004
+ maxTokens: 800,
1005
+ });
1006
+ for await (const text of stream) {
1007
+ output.textContent += text;
1008
+ }
1009
+ const { stopReason, usage: { inputTokens, outputTokens } } = await stream.result;
1010
+ ```
1011
+
1012
+ - **Tiers**: `model: 'fast'` (the default; Claude Haiku: quick, cheap answers) or `'smart'` (Claude Sonnet: harder
1013
+ reasoning, longer answers).
1014
+ - **Request**: `prompt` (one user message) or `messages` (1–50 `{ role: 'user' | 'assistant', content }`, alternating,
1015
+ first and last from the user), plus optional `system` (up to 32 KB), `maxTokens` (default 1024, up to the tier's
1016
+ `maxOutputTokens`), `temperature` (0–1) and `stopSequences` (up to 4). At most 256 KB in all.
1017
+ - **Result**: `{ text, stopReason, usage: { inputTokens, outputTokens }, id, model }`. `stopReason` is `end_turn`,
1018
+ `max_tokens` (raise `maxTokens`), `stop_sequence` or `content_filtered`.
1019
+ - **Streaming**: `stream(request)` yields the text as it arrives; iterate it once, then `await stream.result`. Errors
1020
+ are thrown by the loop and by `result`. `stream.abort()`, the `signal` option or leaving the loop early (`break`)
1021
+ stops the request: both then reject with an `AbortError` (tokens generated so far still count).
1022
+ - **Usage**: `app.ai.info()` resolves to `{ tiers: [{ name, maxOutputTokens, dailyTokens, usedTokens }],
1023
+ maxInputBytes, requestsPerMinute }`, with today's use for the current client.
1024
+ - **Limits**: a daily token budget (input + output) per app, client and tier, reset at UTC midnight, and a per-minute
1025
+ request limit per app and client (all tiers). A request is checked against the budget before it runs and counted
1026
+ after, so the last one of the day can go over by its own size. Calls are **never retried** for you: each one costs
1027
+ tokens.
1028
+ - **Privacy**: prompts and answers are never logged or stored by the platform; it logs only the app, version, client,
1029
+ user, tier, token counts, duration and outcome. Don't send secrets or personal data you don't need to.
1030
+
1031
+ **Errors** come back as `HawkApiError`:
1032
+
1033
+ | Code | Status | Meaning |
1034
+ |---|---|---|
1035
+ | `scope_missing` | 403 | The app doesn't have the `ai:invoke` scope. |
1036
+ | `invalid_ai_request` | 400 | A field is missing or wrong; the response's `errors` (in `error.body`) lists each one. |
1037
+ | `request_too_large` | 413 | The request is over 256 KB. |
1038
+ | `too_many_requests` | 429 | Over the per-minute limit; wait `retryAfterMs`. |
1039
+ | `ai_budget_exhausted` | 429 | The tier's daily tokens for this client are used up; `retryAfterMs` is the time until UTC midnight. Try the other tier, or wait. |
1040
+ | `ai_not_configured` | 503 | AI isn't enabled on this platform. |
1041
+ | `ai_unavailable`, `ai_throttled`, `ai_failed` | 502 | The model failed while answering (the stream's `error`): try again later. Text already streamed is incomplete. |
1042
+
1043
+ ## Your own backend
1044
+
1045
+ If your app has a server of its own (an API, a database, a job), it can trust who is calling and act for them with
1046
+ the **app token**, without any login of its own.
1047
+
1048
+ ```ts
1049
+ // In the app: send the token as a Bearer header on every call to your backend.
1050
+ const response = await fetch('https://my-tool-api.example.com/reports', {
1051
+ headers: { Authorization: `Bearer ${await app.getToken()}` },
1052
+ });
1053
+ ```
1054
+
1055
+ ```ts
1056
+ // On your server (Node, with `jose`): verify it against the platform's public keys.
1057
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
1058
+
1059
+ const keys = createRemoteJWKSet(new URL('https://<platform>/.well-known/jwks.json'));
1060
+
1061
+ const { payload } = await jwtVerify(token, keys, {
1062
+ issuer: 'urn:hawk-apps:prod', // urn:hawk-apps:dev on dev
1063
+ audience: 'hawk-app:my-tool', // your slug
1064
+ algorithms: ['RS256'],
1065
+ });
1066
+ // payload.client_id, payload.sub, payload.email, payload.roles, payload.scope ...
1067
+ ```
1068
+
1069
+ **Auth is a Bearer token, never a cookie.** Your app runs in a cross-site iframe, where browsers block or partition
1070
+ cookies (Safari always, Chrome increasingly), so a session cookie that works when you open the app directly fails
1071
+ inside the HawkSearch dashboard or Spark. Ask for a fresh token per request: `app.getToken()` caches it and refreshes it before it expires.
1072
+
1073
+ The token contract is stable, and any JWT library that reads a JWKS can check it:
1074
+
1075
+ | Claim | Value |
1076
+ |---|---|
1077
+ | header `alg` / `kid` | `RS256`, and the id of the key in the JWKS (keys rotate; let your library pick by `kid`) |
1078
+ | `iss` | `urn:hawk-apps:dev` or `urn:hawk-apps:prod` |
1079
+ | `aud` | `["hawk-apps-api", "hawk-app:<slug>"]`: check for your own `hawk-app:<slug>` |
1080
+ | `exp`, `iat`, `nbf` | about 10 minutes of validity |
1081
+ | `sub`, `email` | the user (id, and email when the host has one) |
1082
+ | `client_id`, `account_id` | the tenant (`account_id` when the host has one): scope everything you store by `client_id` |
1083
+ | `analytics` | `"none"` when the client has no analytics data set up (missing otherwise) |
1084
+ | `roles`, `internal` | the user's roles, and whether they are Bridgeline staff |
1085
+ | `app`, `host`, `scope` | your slug, the host (e.g. `spark`), the granted scopes |
1086
+ | `jti` | a unique id for the token |
1087
+
1088
+ Your server can also call the platform with the same token (`Authorization: Bearer <token>`, e.g.
1089
+ `POST https://<platform>/v1/analytics/searches`), so data can be fetched server-side for the current user and client.
1090
+ Treat the token like a password: don't log it or store it.
1091
+
1092
+ Server-rendered apps (SSR, where the server needs the user on the very first page load) are planned: the manifest
1093
+ already has `"hosting": "static"`, and `"server"` is reserved for them.
1094
+
1095
+ ## Errors
1096
+
1097
+ Failed data calls throw `HawkApiError` with `status` and `code`; failed host requests throw `HawkBridgeError` with
1098
+ `code`.
1099
+
1100
+ | Code | Meaning |
1101
+ |---|---|
1102
+ | `not_embedded` | The page was opened directly, not inside a host. |
1103
+ | `host_not_allowed` | The host isn't in `allowedHostOrigins`. |
1104
+ | `timeout` | The host didn't answer in time. |
1105
+ | `report_not_allowed` | The report name isn't a plain kebab-case name, or the platform blocks that report. Any other name is passed to the analytics API (`report('')` is its ad-hoc / natural-language root); a name it doesn't know comes back as its own `400`. |
1106
+ | `analytics_not_available` | Analytics isn't set up for this client (at once when `context.tenant.analyticsProvisioned` is `false`). |
1107
+ | `hawksearch_not_available` | No HawkSearch access: add `hawksearch:read` to your scopes and deploy again. |
1108
+ | `version_not_allowed` | You put an API version in a dashboard path: use `Synonym`, not `v11/Synonym`. |
1109
+ | `path_not_allowed` | The dashboard path isn't allowed (writes: see [Writing to HawkSearch](#writing-to-hawksearch-hawksearchwrite)). |
1110
+ | `hawksearch_write_needs_site_key`, `too_many_items`, `too_many_requests`, `invalid_path` | Dashboard writes: see [Writing to HawkSearch](#writing-to-hawksearch-hawksearchwrite). |
1111
+ | `query_*`, `invalid_params`, … | Custom queries: see [Custom data (queries)](#custom-data-queries-dataread). |
1112
+ | `function_*`, `functions_not_enabled`, `secret_missing`, … | Functions: see [Functions](#functions-functionsinvoke). |
1113
+ | `email_*`, `invalid_email` | Email: see [Email](#email-emailsend). |
1114
+ | `config_key_not_declared`, `invalid_config_value` | Config values: see [App config](#app-config). |
1115
+ | `engine_*`, `parameter_*`, `invalid_value`, … | System parameters: see [HawkSearch system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite). |
1116
+ | `ai_*`, `invalid_ai_request` | AI: see [AI](#ai-aiinvoke). |
1117
+ | `*_unavailable`, `*_credentials_rejected` | Something is wrong on our side: retry later, and tell Engineering if it persists. |
1118
+
1119
+ A `403` without one of these codes means the app lacks the scope for that call.
1120
+
1121
+ ## Errors and usage
1122
+
1123
+ `createApp` reports, on its own, one open per page load (the full app or the view showing) and the page's uncaught
1124
+ errors and unhandled rejections: at most 10 per page load, each once, without query strings. The app's team sees them
1125
+ with `hawk-app errors` and `hawk-app usage`, and Bridgeline in the portal (platform/docs/telemetry.md).
1126
+
1127
+ ```ts
1128
+ try {
1129
+ await app.functions.call('refresh', {});
1130
+ } catch (error) {
1131
+ app.reportError(error); // reported like an uncaught one, and the app shows its own message
1132
+ showMessage('Couldn’t refresh: try again.');
1133
+ }
1134
+ ```
1135
+
1136
+ `createApp({ reportErrors: false, reportOpens: false })` turns either off.
1137
+
1138
+ ## Talk to the host
1139
+
1140
+ ```ts
1141
+ await app.setTitle('Synonyms review'); // the host's page title
1142
+ await app.toast('Export ready', 'success'); // info | success | warning | error
1143
+ await app.navigate({ intent: 'app', slug: 'other-tool' }); // also: 'report', 'settings', 'external' (https only)
1144
+ await app.resize(); // when autoResize is off
1145
+ ```
1146
+
1147
+ ### Ask the host's assistant: `assistant`
1148
+
1149
+ With the `assistant` scope, an app asks the host's assistant (HawkSearch: Hawk Assistant) a question as the user. The
1150
+ user sees the question and the answer in the assistant's chat, so call it only from a click:
1151
+
1152
+ ```ts
1153
+ // Show the button only while the host has an assistant: it may load after the app (HawkSearch loads it through GTM).
1154
+ const [ready, setReady] = useState(!!app.context.features?.assistant);
1155
+ useEffect(() => app.onContextChange((c) => setReady(!!c.features?.assistant)), [app]);
1156
+
1157
+ <Button disabled={!ready} onClick={() => app.assistant.ask('Which recommendation widgets earn the most this quarter?')}>
1158
+ Ask Hawk Assistant
1159
+ </Button>
1160
+ ```
1161
+
1162
+ Give the assistant what's on screen with `app.assistant.setContext({ ... })` (a JSON object, at most 50 KB; `null`
1163
+ clears it): it goes with every question, the app's and the ones the user types, until the app replaces or clears it, and
1164
+ the host clears it when the app closes. Keep it to the figures on screen, in plain names and numbers, and keep the
1165
+ questions themselves plain:
1166
+
1167
+ ```ts
1168
+ useEffect(() => void app.assistant.setContext({ page: 'Recommendation Dashboard', range, totals, widgets }), [range, totals, widgets]);
1169
+ await app.assistant.ask('Which widgets are taking up space?');
1170
+ ```
1171
+
1172
+ A message is at most 2,000 characters. Errors (`HawkBridgeError`): `unsupported` (no assistant on the page), `scope_missing`,
1173
+ `invalid_request`. Hosts: `<hawk-app-frame>` fires a cancelable `hawk-assistant` event (`detail.message`; `hawk-assistant-context` with `detail.context` for the context); a host that
1174
+ answers it itself calls `preventDefault()` and sets the element's `assistant` attribute. Otherwise the element sends the
1175
+ message to `window.HawkAI.Assistant.sendMessage` (the context to `setContext`, kept until the assistant loads) when the page has it, and tells the app (a `contextChanged` with
1176
+ `features.assistant`) when it loads.
1177
+
1178
+ | Intent | Goes to |
1179
+ | --- | --- |
1180
+ | `{ intent: 'app', slug, params? }` | Another app |
1181
+ | `{ intent: 'report', id? }` | The report list (HawkSearch: Report Library); with an `id`, that report where the host supports it |
1182
+ | `{ intent: 'settings', section? }` | The host's settings |
1183
+ | `{ intent: 'external', url }` | An https page in a new tab |
1184
+
1185
+ Navigation takes *intents*, not URLs, so the same app works in every host.
1186
+
1187
+ ## Look and feel
1188
+
1189
+ Every template gets this from the UI kit ([`@bridgeline-digital/hawk-app-ui`](../ui/README.md)), Spark's look in
1190
+ the host's colors and mode: a React + MUI app its theme, provider and components; any other app (vanilla, Preact,
1191
+ React, Next) its stylesheet and custom elements. The rest of this section is for apps that style themselves.
1192
+
1193
+ With `applyTheme` on, the host's design tokens are set as CSS variables on `<html>`, and
1194
+ `<html data-hawk-theme="light|dark">` matches the host. Use them with your own fallbacks so the app also renders on
1195
+ its own:
1196
+
1197
+ ```css
1198
+ body {
1199
+ background: var(--hawk-color-background, #f6f7f9);
1200
+ color: var(--hawk-color-text-default, #1f2933);
1201
+ font-family: var(--hawk-font-default, system-ui, sans-serif);
1202
+ }
1203
+ button {
1204
+ background: var(--hawk-color-button-accent-background, #ff6a13);
1205
+ color: var(--hawk-color-button-accent-text, #fff);
1206
+ border-radius: var(--hawk-border-radius, 6px);
1207
+ }
1208
+ ```
1209
+
1210
+ Available: `--hawk-color-background`, `--hawk-color-background-container`, `--hawk-color-background-primary`,
1211
+ `--hawk-color-background-accent`, `--hawk-color-text-default`, `--hawk-color-text-heading`,
1212
+ `--hawk-color-text-secondary`, `--hawk-color-text-accent`, `--hawk-color-border-default`, `--hawk-color-border-accent`,
1213
+ `--hawk-color-button-accent-background`, `--hawk-color-button-accent-text`, `--hawk-font-default`,
1214
+ `--hawk-border-radius`.
1215
+
1216
+ The host already shows your app's name above the frame, so you don't need a page heading.
1217
+
1218
+ ### Dialogs cover the whole page
1219
+
1220
+ An open dialog (any `[role="dialog"]`, e.g. every MUI Dialog or Drawer) covers the host's whole page, not just the
1221
+ frame, with nothing to call:
1222
+ - **A frame that's fully on screen and big enough**: the host dims the page around the frame to match your dialog's
1223
+ backdrop (`modalBackdrop` on the host, default MUI's `rgba(0, 0, 0, 0.5)`). A click there sends Escape to the dialog.
1224
+ - **Any other frame** (a preview, a slot, a short or narrow frame, or a tall page scrolled partly out of view, where a
1225
+ dialog fixed to the frame could open off screen): the host lifts the frame over the whole window while the dialog is
1226
+ open. The bridge keeps your page exactly where the frame was (same width, same scroll, clipped to the frame's box)
1227
+ on an otherwise transparent page, so the dialog shows full size over the dimmed host page with your content still
1228
+ behind it; the frame goes back when the dialog closes.
1229
+ - The host page doesn't scroll meanwhile and keeps its scroll position, and auto-resize waits for the dialog to close.
1230
+ - Center dialogs in the visible part of the frame (`--hawk-visible-top` and `--hawk-visible-height`, see the templates'
1231
+ theme). `createApp({ hostModals: false })` turns this off.
1232
+
1233
+ ## Local development
1234
+
1235
+ Run your dev server on port 3100 (the templates do), then open the app in HawkSearch with `?devUrl=` pointing at it:
1236
+
1237
+ ```
1238
+ <HawkSearch>/admin/apps/my-tool?devUrl=http://localhost:3100/my-tool/
1239
+ ```
1240
+
1241
+ The HawkSearch dashboard loads your local copy instead of the deployed one, with the real context and real data, and hot reload works as
1242
+ usual. Dev mode is for Bridgeline users only and accepts only `http://localhost` addresses. The app must be registered
1243
+ for the client you're on (a draft is enough), and the dev platform accepts data calls from ports 3100 to 3103.
1244
+
1245
+ ## `hawk-app.json`
1246
+
1247
+ The CLI sends this file to the platform with every `hawk-app deploy` (and `hawk-app dev`). It sets your app's visibility and what data it can read.
1248
+ Which clients have the app is not in this file: manage them in the Hawk Analytics portal or with `npx hawk-app clients`.
1249
+
1250
+ ```json
1251
+ {
1252
+ "slug": "my-tool",
1253
+ "name": "My Tool",
1254
+ "description": "What it does, in one line.",
1255
+ "visibility": "allowList",
1256
+ "scopes": ["reports:read"]
1257
+ }
1258
+ ```
1259
+
1260
+ | Field | |
1261
+ |---|---|
1262
+ | `slug` | Unique id and the app's folder on the CDN (`/<slug>/`, one `v<n>/` folder per deploy). Can't change later. |
1263
+ | `icon` | An `.svg` (drawn in the host's color) or `.png` (shown as is) file next to `hawk-app.json`, e.g. `icon.svg`. New apps get a default `icon.svg`; without one, hosts show a default icon. |
1264
+ | `hosting` | `static` (the default): built files on the apps CDN. `server` is reserved for server-rendered apps and not accepted yet. |
1265
+ | `visibility` | `allowList` (only the clients given the app), `all` (every client except excluded ones), or `internal` (Bridgeline staff). The clients themselves are managed in the Hawk Analytics portal or with `npx hawk-app clients add`/`exclude`/`remove`; a `hawk-app.json` with `clients` or `excludeClients` is refused. |
1266
+ | `listed` | `true` (the default) shows the app on the host's Apps page. `false` leaves it off that list, and the host hides its own title and back button for it, but it still opens by its direct URL (e.g. linked from another app or page). It doesn't change who can open it: `visibility` and the app's clients still apply. |
1267
+ | `scopes` | `reports:read` for analytics, `hawksearch:read` for the dashboard API, `hawksearch:write` to [write to it](#writing-to-hawksearch-hawksearchwrite), `data:read` for [custom queries](#custom-data-queries-dataread), `functions:invoke` for [functions](#functions-functionsinvoke), `email:send` for [email](#email-emailsend), `hawksearch:sysparams:write` for [system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite), `hawksearch:search` for [search](#hawksearch-search-hawksearchsearch), `hawksearch:features:write` for [features](#hawksearch-features-hawksearchfeatureswrite), `hawksearch:index` for [index rebuilds](#hawksearch-index-rebuilds-hawksearchindex), `hawksearch:index:items` and `hawksearch:index:manage` for [the Indexing API](#the-indexing-api-hawksearchindex-indexitems-indexmanage), `hawksearch:recommendations` for [recommendations](#hawksearch-recommendations-hawksearchrecommendations), `storage` for [storage](#storage-storage), `lambdas:invoke` for [external Lambdas](#external-lambdas-lambdasinvoke), `ai:invoke` for [AI](#ai-aiinvoke), `assistant` to [ask the host's assistant](#ask-the-hosts-assistant-assistant). |
1268
+ | `functions` | Your [functions](#functions-functionsinvoke) and their settings: `{ "<name>": { "job", "timeoutSeconds", "memoryMb", "secrets" } }`. Recorded with each version. |
1269
+ | `schedules` | [Schedules](#schedules) for job functions: `{ "<name>": { "function", "cron", "timezone" } }`, at most 5, at most hourly. Needs `functions:invoke`. Reviewed like scopes. |
1270
+ | `systemParameters` | The HawkSearch [system parameters](#hawksearch-system-parameters-hawksearchsysparamswrite) the app may read and set, e.g. `["Timezone"]`. Needs `hawksearch:sysparams:write`. Reviewed like scopes. |
1271
+ | `config` | [App config](#app-config) keys: `{ "<key>": { "type", "label", "description", "default" } }`, at most 50. The values are set outside the file (portal or `npx hawk-app config`) and aren't secret. Reviewed like scopes. |
1272
+ | `externalOrigins` | Outside origins for scripts, styles, fonts, images, frames and API calls. New apps start with `[]` (none; the vanilla template: `["https://cdn.jsdelivr.net"]`). `["https://api.example.com", "https://*.cdn.example.com"]` allows only those. `["*"]` (also what a missing field means) allows any https origin and fails the review check. https or wss, no paths. Entries starting with `img:` are for images only: `"img:*"` (any https origin, e.g. a client's product images) or `"img:https://cdn.example.com"`. |
1273
+
1274
+ Run `npx hawk-app deploy --dry-run` to see what would change without making a version (use it on pull requests), and
1275
+ `npx hawk-app clients` to see which clients have the app.
1276
+
1277
+ **Who releases it.** There's no `status` field (an old one is ignored with a warning): every `hawk-app deploy` is a
1278
+ new version, with the hawk-app.json it was deployed with, and Bridgeline staff review apps on the Apps page in
1279
+ HawkSearch. See [versions, statuses and aliases](../../docs/versions-and-aliases.md).
1280
+ - Each version has a status: **draft** (deployed), **waiting** (submitted for approval), **approved** or **rejected**.
1281
+ Only Bridgeline staff see versions that aren't approved, on the app's clients (or every client with
1282
+ `"visibility": "all"`).
1283
+ - An approver **releases** a version: released to everyone, the built-in `default` alias moves to it, and every
1284
+ client without another alias gets it. Other aliases (e.g. `pilot`) let some clients get a different version.
1285
+ - Once released, each new version and each settings change (name, scopes, `externalOrigins`, …) waits for approval,
1286
+ and clients keep the released version until then. `npx hawk-app status` shows what's waiting.
1287
+ - Client changes (Hawk Analytics portal or `npx hawk-app clients`) apply at once, without a deploy or approval, and
1288
+ are recorded in the app's history.
1289
+ - Approvers can set an app to **auto-approve**, so new versions and changes are released straight away.
1290
+ - Apps using queries, functions, system parameters, dashboard writes, AI, external Lambdas, HawkSearch features or index
1291
+ rebuilds are never auto-approved.
1292
+
1293
+ **Approval.** Members of the Hawk-App-Developers group submit a draft, new version or settings change for approval in HawkSearch; members of
1294
+ the Hawk-App-Approvers group approve, reject (with a reason) or force approve it. A new deploy or settings change clears a
1295
+ waiting request, so it has to be submitted again. `npx hawk-app status` shows whether it's waiting for approval or was
1296
+ rejected, and why.
1297
+
1298
+ ## Good to know
1299
+
1300
+ - **No secrets in the app.** Everything in it is public. Credentials stay on the platform; API keys of your own go in
1301
+ function secrets (see [Functions](#functions-functionsinvoke)).
1302
+ - **Outside origins are up to you.** An app can load scripts, styles, fonts and images from, embed, and call
1303
+ (`fetch`, WebSockets) only the origins in `externalOrigins` (`"*"` allows any https origin, and fails the review
1304
+ check). List the origins it really uses: the apps CDN blocks everything else (a Content-Security-Policy), so a compromised npm dependency can't send
1305
+ data anywhere new. The platform API is always allowed. To show images from any site (for example a client's
1306
+ product images) without opening anything else, use `"img:*"`. Dev mode on localhost has no such policy, so check a
1307
+ deployed version in HawkSearch before it's released.
1308
+ - **Static files only.** Build to plain HTML, JS and CSS (`next build` with `output: 'export'`, `vite build`, …), with
1309
+ the base path read from `HAWK_APP_BASE_PATH` (`/<slug>/v<n>`, set by `hawk-app deploy`; the templates do this).
1310
+ - **Tokens are handled for you.** `app.getToken()` exists for apps with a backend of their own; the token can be
1311
+ verified against the platform's JWKS.
1312
+ - **Changes to client data are reviewed.** Apps change client data only through scopes that allow it:
1313
+ `hawksearch:write`, `hawksearch:sysparams:write`, `hawksearch:features:write`, the `hawksearch:index` scopes and
1314
+ `storage` (the app's own data). An approver reviews each one.
1315
+