@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.
- package/LICENSE +7 -7
- package/README.md +770 -770
- package/agents/AGENTS.md +192 -192
- package/agents/project/custom.md +21 -21
- package/agents/project/next-mui.md +21 -21
- package/agents/project/next.md +24 -24
- package/agents/project/vanilla.md +22 -22
- package/agents/project/vite.md +19 -19
- package/bin/hawk-app.mjs +0 -0
- package/docs/bridge/README.md +1315 -1315
- package/docs/showcase/README.md +47 -47
- package/docs/showcase/functions/echo.ts +12 -12
- package/docs/showcase/functions/long-task.ts +31 -31
- package/docs/showcase/functions/showcase.ts +186 -186
- package/docs/showcase/hawk-app.json +73 -73
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
- package/docs/showcase/queries/searches-by-day.json +6 -6
- package/docs/showcase/queries/searches-by-day.sql +7 -7
- package/docs/showcase/src/App.tsx +1436 -1436
- package/docs/showcase/src/main.tsx +10 -10
- package/docs/showcase/src/styles.css +71 -71
- package/docs/showcase/src/useHawkApp.ts +44 -44
- package/docs/showcase/src/vite-env.d.ts +1 -1
- package/docs/ui/README.md +166 -166
- package/package.json +1 -1
- package/schema/hawk-app.schema.json +611 -611
- package/src/accessibility.mjs +105 -105
- package/src/agents.mjs +145 -145
- package/src/aliases.mjs +30 -30
- package/src/app-icon.mjs +82 -82
- package/src/channel.mjs +116 -116
- package/src/cli.mjs +825 -825
- package/src/clients.mjs +221 -221
- package/src/config.mjs +148 -148
- package/src/create.mjs +186 -186
- package/src/credentials.mjs +56 -56
- package/src/data.mjs +169 -169
- package/src/deploy.mjs +349 -349
- package/src/dev.mjs +459 -459
- package/src/doctor.mjs +176 -176
- package/src/env.mjs +140 -140
- package/src/functions.mjs +313 -313
- package/src/login.mjs +166 -166
- package/src/logs.mjs +64 -64
- package/src/manifest.mjs +425 -425
- package/src/mcp-docs.mjs +168 -168
- package/src/mcp-hawksearch.mjs +342 -342
- package/src/mcp-reports.mjs +130 -130
- package/src/mcp-rules.mjs +109 -109
- package/src/mcp.mjs +539 -539
- package/src/platform.mjs +116 -116
- package/src/project-checks.mjs +153 -153
- package/src/queries.mjs +289 -289
- package/src/scope-check.mjs +91 -91
- package/src/secrets.mjs +86 -86
- package/src/slug-guard.mjs +80 -80
- package/src/sso.mjs +182 -182
- package/src/telemetry.mjs +50 -50
- package/src/upgrade.mjs +406 -406
- package/src/versions.mjs +102 -102
- package/templates/next/next.config.ts +13 -13
- package/templates/next/package.json +26 -26
- package/templates/next/src/lib/useHawkApp.ts +46 -46
- package/templates/next-mui/next.config.ts +13 -13
- package/templates/next-mui/package.json +30 -30
- package/templates/next-mui/src/app/layout.tsx +19 -19
- package/templates/next-mui/src/app/page.tsx +78 -78
- package/templates/next-mui/src/app/providers.tsx +10 -10
- package/templates/preact/package.json +26 -26
- package/templates/preact/src/useHawkApp.ts +44 -44
- package/templates/preact/src/vite-env.d.ts +1 -1
- package/templates/preact/tsconfig.json +17 -17
- package/templates/preact/vite.config.ts +11 -11
- package/templates/react/package.json +29 -29
- package/templates/react/src/useHawkApp.ts +44 -44
- package/templates/react/src/vite-env.d.ts +1 -1
- package/templates/react/tsconfig.json +16 -16
- package/templates/react/vite.config.ts +11 -11
- package/templates/vanilla/package.json +15 -15
package/docs/bridge/README.md
CHANGED
|
@@ -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
|
+
|