@bridgeline-digital/hawk-app-cli 0.1.1-dev.20261021024847 → 0.1.1-dev.20261026155507

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +188 -24
  2. package/agents/AGENTS.md +32 -8
  3. package/claude-plugin/.claude-plugin/marketplace.json +14 -0
  4. package/claude-plugin/hawk-apps/.claude-plugin/plugin.json +8 -0
  5. package/claude-plugin/hawk-apps/.mcp.json +8 -0
  6. package/claude-plugin/hawk-apps/scripts/mcp.mjs +35 -0
  7. package/claude-plugin/hawk-apps/skills/add-function/SKILL.md +46 -0
  8. package/claude-plugin/hawk-apps/skills/add-query/SKILL.md +31 -0
  9. package/claude-plugin/hawk-apps/skills/new-screen/SKILL.md +36 -0
  10. package/claude-plugin/hawk-apps/skills/prepare-review/SKILL.md +36 -0
  11. package/docs/bridge/README.md +380 -24
  12. package/docs/showcase/hawk-app.json +8 -3
  13. package/docs/showcase/src/App.tsx +255 -1
  14. package/docs/ui/README.md +7 -0
  15. package/package.json +12 -1
  16. package/schema/hawk-app.schema.json +226 -2
  17. package/src/agents.mjs +38 -7
  18. package/src/claude-plugin.mjs +67 -0
  19. package/src/cli.mjs +56 -7
  20. package/src/deploy.mjs +16 -8
  21. package/src/dev.mjs +177 -4
  22. package/src/doctor.mjs +17 -3
  23. package/src/error-brief.mjs +128 -0
  24. package/src/function-test-runner.mjs +16 -0
  25. package/src/function-test.d.ts +51 -0
  26. package/src/function-test.mjs +303 -0
  27. package/src/functions.mjs +35 -21
  28. package/src/manifest.mjs +240 -1
  29. package/src/mcp-hawksearch.mjs +14 -4
  30. package/src/mcp.mjs +16 -0
  31. package/src/platform.mjs +64 -0
  32. package/src/project-checks.mjs +19 -5
  33. package/src/scope-check.mjs +9 -2
  34. package/src/sourcemaps.mjs +168 -0
  35. package/src/telemetry.mjs +38 -4
  36. package/src/template-history.json +8 -4
  37. package/src/test-command.mjs +88 -0
  38. package/templates/next/next.config.ts +3 -0
  39. package/templates/next/src/app/page.tsx +6 -0
  40. package/templates/next-mui/next.config.ts +3 -0
  41. package/templates/next-mui/src/app/page.tsx +3 -1
  42. package/templates/preact/src/App.tsx +6 -0
  43. package/templates/preact/vite.config.ts +3 -0
  44. package/templates/react/src/App.tsx +6 -0
  45. package/templates/react/vite.config.ts +3 -0
  46. package/templates/vanilla/public/app.js +2 -0
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: prepare-review
3
+ description: Get a Hawk App ready to submit for approval in HawkSearch - run the approvers' automated checks (bundle, dependencies, accessibility, outside origins, queries, functions, scopes), fix what fails and say what's left. Use before submitting, after a big change, or when the user asks whether the app will pass review.
4
+ ---
5
+
6
+ # Prepare for review
7
+
8
+ Approvers see automated checks in HawkSearch's approve dialog; a failed one forces them to override it. Get every check
9
+ green, or explain to the user why one can't be.
10
+
11
+ 1. **Validate with the build.** Run the hawk-apps `validate` tool with `build: true`. Fix what it reports in the code;
12
+ ask the user before changing `scopes` or `externalOrigins` in `hawk-app.json`.
13
+ 2. **The review checklist** (fix or explain each):
14
+ - **Bundle:** under 20 MB in total, no file over 5 MB, not 50% (and 1 MB) bigger than the released version.
15
+ - **Dependencies:** no high or critical findings in `npm run app:audit` (production dependencies).
16
+ - **Accessibility:** no critical axe issues (`validate --build` lists them): labels, `alt`, one `<main>`, headings
17
+ in order.
18
+ - **Outside origins:** `externalOrigins` lists each https origin the app calls or loads from; `["*"]` fails
19
+ (`"img:*"` is fine for images).
20
+ - **Scopes:** each one is used by the code, and every bridge call's scope is there (`validate` warns about missing
21
+ ones). Fewer is better: elevated scopes (`data:read`, `functions:invoke`, `hawksearch:write`, `ai:invoke`, …) are
22
+ never auto-approved.
23
+ - **Queries:** each has `{{partitions(...)}}`, names its columns and has a `LIMIT` on lists.
24
+ - **Functions and external Lambdas:** shown to the approver with their code and settings; Engineering must have
25
+ enabled functions. `npx hawk-app test` passes, if the app has function tests.
26
+ - **Client API endpoints (`api`):** each answers only what the client's own users could see in the app.
27
+ - **Actions (`actions`):** each target reads only what its view needs, with stable selectors (ids, `data-*`
28
+ attributes, element names); approvers see every target, and an app with actions is never auto-approved.
29
+ - **No secrets** anywhere in the app or the build.
30
+ 3. **What approvers will see.** Run `status`: the review checks on the newest version, and `deploy` with
31
+ `dryRun: true`: what `hawk-app.json` would change. If the newest version is older than the code, say so: the user
32
+ decides when to deploy.
33
+ 4. **Errors.** Run `errors` and fix what the deployed versions threw, the most frequent first: `error_brief` with an
34
+ error's id gives its stack mapped to the source, the failed platform calls and what changed since it last worked.
35
+ 5. **Finish** with a short list: ready to submit (open the app with `npx hawk-app open` and click **Submit for
36
+ approval**), or what's left. Never deploy to prod or submit for the user.
@@ -125,6 +125,82 @@ them in `hawk-app.json`:
125
125
  `data:read`): hide or disable what writes, sends, stores or asks the assistant, and say it's a trial.
126
126
  `navigate({ intent: 'app' })` opens the host's request dialog.
127
127
 
128
+ ### Actions: start the app from HawkSearch's screens
129
+
130
+ **Actions** put the app where the work is: a button in HawkSearch's own pages (by a search term in a report, on an AI
131
+ Advisor insight…) that opens one of the app's views in a dialog, already on that thing. The app says where, with
132
+ **targets**: the page (a regular expression on its path), the things (a CSS selector: one button per match), where the
133
+ button goes, and how each of the view's params is read from the thing. Deploy, and once the version is approved the
134
+ buttons appear in HawkSearch: nothing changes in HawkSearch for it.
135
+
136
+ ```jsonc
137
+ "views": {
138
+ "fix": { "path": "views/fix", "title": "Fix a search", "height": 420, "params": ["keyword", "category"] }
139
+ },
140
+ "actions": {
141
+ "fix-keyword": {
142
+ "label": "Fix with Zero Results Fixer",
143
+ "view": "fix",
144
+ "targets": {
145
+ "hawksearch": [
146
+ {
147
+ "path": "/admin/advanced-analytics/(top-queries|query-with-poor-results)/?$", // the page
148
+ "selector": "bl-table tbody.table__body > tr.table__row", // each thing: a table row
149
+ "anchor": "td.table__cell:first-child", // the button goes in it
150
+ "params": { "keyword": { "selector": "td.table__cell:first-child" } } // its text
151
+ },
152
+ {
153
+ "path": "/admin/recommendations/?$",
154
+ "selector": ".recommendations-list__item:not(.recommendations-list__item--empty)",
155
+ "anchor": ".recommendations-list__text",
156
+ "position": "after",
157
+ "params": {
158
+ "keyword": { "selector": ".recommendations-list__text", "pattern": "[\"“”]([^\"“”]{1,100})[\"“”]" },
159
+ "category": { "selector": ".recommendations-list__category-tag" }
160
+ }
161
+ }
162
+ ]
163
+ }
164
+ }
165
+ }
166
+ ```
167
+
168
+ - **A target:** `path` (a regular expression on the page's path), `selector` (each thing), `anchor` (a selector inside
169
+ the thing; default the thing) and `position` (`append`, the default, `prepend`, `before` or `after` it), and `params`:
170
+ for each of the view's params, `selector` (an element inside the thing; default the thing), `attribute` (default its
171
+ text) and `pattern` (a regular expression; its first group is the value). Values are text, at most 200 characters.
172
+ - **Data attributes (optional):** a param's `data` names a `data-*` attribute without its `data-` (`"keyword"` reads
173
+ `data-keyword`), on the thing or the first element inside it that has one. When it has a value, that value is used
174
+ as is (no pattern); when it's missing or empty, the param's `selector`, `attribute` and `pattern` read it as above.
175
+ So a target can be ready for an attribute HawkSearch adds later, e.g. `"keyword": { "data": "hawk-keyword",
176
+ "selector": "td.table__cell:first-child" }`. With `data` alone, nothing is read until the attribute is there.
177
+ - **Pick stable selectors:** ids, `data-*` attributes and element names over styling classes. HawkSearch doesn't know
178
+ about your selectors: when it changes its markup, your buttons stop appearing until you deploy new targets.
179
+ - **Approval:** `actions` is a setting that always needs an approver (never auto-approve): reviewers see each target and
180
+ what it reads from the page. Read only what the view needs. Settings and the Apps pages never show actions.
181
+
182
+ ```tsx
183
+ // src/app/views/fix/page.tsx
184
+ const { app, context } = useHawk();
185
+ const keyword = context.view?.params.keyword?.trim(); // only the params the view declares arrive
186
+ // context.view.action: { name: 'fix-keyword' } (null when the view isn't opened by an action)
187
+
188
+ async function saved() {
189
+ await app.toast(`Synonym created for "${keyword}"`, 'success'); // shown by the host for an action's view
190
+ await app.action.done({ changed: true }); // the host closes the dialog (and may refresh)
191
+ }
192
+ ```
193
+
194
+ - Each target's params must be ones the view declares (`hawk-app validate` checks it).
195
+ - `app.action.done({ changed })` closes the host's dialog. It does nothing outside an action, and on a host from
196
+ before actions.
197
+ - Params are read from the page, like a URL's query: check them, and handle them missing (the page may not show the
198
+ value). The platform's checks (scopes, client, engine) still apply to every call the view makes.
199
+ - At most 10 actions, 5 targets per host each. `hawk-app dev` has an **Action** menu that opens a view as HawkSearch
200
+ will (type the value in).
201
+ - A complete example, from `hawk-app.json` to release:
202
+ [Fix a search term from HawkSearch's reports](../../docs/examples.md#fix-a-search-term-from-hawksearchs-reports).
203
+
128
204
  ### Hosts: `<hawk-app-container>`
129
205
 
130
206
  Configure once per page, then place containers anywhere:
@@ -153,7 +229,9 @@ configureHawkAppContainers({
153
229
  `full-app` (the entire app instead of a view: `resolveView(app, null)`; its context has no `view`),
154
230
  `min-height` (default 0), `max-height` (default 1200), `fallback` (text when the view can't load and the app has no description),
155
231
  `trial` (a store app's trial view: the host's `resolveView`, `getToken` and `openApp` get `{ trial: true }`, and
156
- the context `trial: true`).
232
+ the context `trial: true`), `action` (`{"name":"fix-keyword"}`: the view was opened from one of the app's
233
+ [actions](#actions-start-the-app-from-hawksearchs-screens); its toasts reach the page and `app.action.done()` fires
234
+ `hawk-action-done`).
157
235
  - **Loading:** when scrolled near, at most `maxConcurrent` (default 3) at a time; a placeholder of the view's height
158
236
  until it connects; the fallback after `loadTimeoutMs` (default 20 s).
159
237
  - **Styling:** the `state` attribute (`idle`, `loading`, `ready`, `fallback`) and a `hawk-view-state` event; the
@@ -256,32 +334,48 @@ server's `analytics_reports` tool reads that spec.
256
334
  ### HawkSearch dashboard API: `hawksearch:read`
257
335
 
258
336
  ```ts
259
- const synonyms = await app.dashboard.get('Synonym');
260
- const fields = await app.dashboard.get('Field');
337
+ const synonyms = await app.dashboard.get('Synonym'); // HawkSearchModel<'Synonym'>[]
338
+ const fields = await app.dashboard.get('Field'); // HawkSearchModel<'FieldModel'>[]
261
339
  // Query parameters: app.dashboard.get('LandingPage', { query: { ... } })
340
+
341
+ // Typed routes of a resource
342
+ const redirects = app.dashboard.resource('redirect');
343
+ const all = await redirects.list();
344
+ const one = await redirects.get(42);
262
345
  ```
263
346
 
264
347
  Any resource of the [HawkSearch Dashboard API](https://developerdocs.hawksearch.com/reference/getting-started-with-your-api):
265
348
  `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
349
+ body as is.
350
+
351
+ **Typed from HawkSearch's API definition.** `app.dashboard.resource(name)` gives a resource's routes: `list()`,
352
+ `get(id)`, `create(body)`, `update(id, body)` and `delete(id)`, only those HawkSearch has for it (`bannerzone` is list
353
+ only, `LandingPage` has no list), with HawkSearch's models: a misspelled field or resource is a compile error.
354
+ `get(name)` of such a resource is typed too, and `HawkSearchModel<'Synonym'>` (or `'RedirectModel'`, `'LandingPage'`,
355
+ …) names a model in your own code. The resources, as HawkSearch spells them: `banner`, `bannerzone`, `boostbury`,
356
+ `campaign`, `Facet`, `Field`, `FieldInfo`, `LandingPage`, `landingPageLayout`, `paginationset`,
357
+ `recommendationvisibility`, `redirect`, `sortingset`, `spellcheck`, `Synonym`, `visibility`, `visitortarget` (paths
358
+ ignore case). Other paths (`LandingPage/urls`, `reports/…`) stay on `get<T>(path)`. The types come from HawkSearch's dev
359
+ API and are refreshed with each bridge release. To change data, see [Writing to HawkSearch](#writing-to-hawksearch-hawksearchwrite). For reporting, use
267
360
  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.
361
+ server's `hawksearch_api` tool. The same scope gives the preview page's tools (`app.hawksearch.preview`: visitor targets,
362
+ indexes, item details, keyword explain): see [Preview like HawkSearch's preview page](#preview-like-hawksearchs-preview-page).
269
363
 
270
364
  ### Writing to HawkSearch: `hawksearch:write`
271
365
 
272
366
  ```ts
273
367
  // Create a synonym
274
- const synonym = await app.dashboard.post('Synonym', {
368
+ const synonym = await app.dashboard.resource('Synonym').create({
275
369
  SearchTerm: 'sneakers',
276
370
  SynonymGroup: 'trainers,running shoes',
277
371
  MultiWay: true,
278
372
  });
279
373
 
280
374
  // Update a redirect
281
- await app.dashboard.put('Redirect/42', { ...redirect, NewUrl: '/sale' });
375
+ await app.dashboard.resource('redirect').update(42, { ...redirect, RedirectUrl: '/sale' });
282
376
 
283
- // Delete a banner
284
- await app.dashboard.delete('Banner/7');
377
+ // Delete a banner (or any path: app.dashboard.delete('Banner/7'))
378
+ await app.dashboard.resource('banner').delete(7);
285
379
  ```
286
380
 
287
381
  `app.dashboard.post(path, body?)`, `put(path, body?)` and `delete(path)` (also on `ctx.platform.dashboard` in
@@ -489,8 +583,8 @@ what `handler` returned (up to 6 MB). It waits up to `timeoutMs` (default 30 s)
489
583
  | `ctx.app.slug`, `ctx.version` | Your app and the version the caller has open (the function code is that version's). |
490
584
  | `ctx.secrets.NAME` | The secrets the function declares in `hawk-app.json` (see below). Nothing else. |
491
585
  | `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`). |
586
+ | `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.preview`](#preview-like-hawksearchs-preview-page), [`hawkai`](#hawkai-agents-and-smart-responses-hawkairead), [`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. |
587
+ | `ctx.job`, `ctx.trigger` | For a [background job](#background-jobs): `{ id, progress(percent?, message?) }` (null for a call), and what started the run (`call`, `schedule`, [`event`](#events) or [`api`](#client-api)). |
494
588
  | `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
589
 
496
590
  The deploy wraps your `handler` with `toLambdaHandler` from `@bridgeline-digital/hawk-app-bridge/server` and
@@ -587,7 +681,8 @@ try {
587
681
  pick the job up again after a reload.
588
682
  - In the function, `ctx.job` is `{ id, progress(percent?, message?) }` (null for a call). `progress` sends at most
589
683
  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 }`.
684
+ `{ type: 'call' }`, `{ type: 'schedule', schedule, enabledBy }`, `{ type: 'event', event, enabledBy? }` or
685
+ `{ type: 'api', endpoint }` (a [client API](#client-api) call).
591
686
  - `ctx.platform.functions.start/job/wait` start other jobs from a function, e.g. to fan work out.
592
687
 
593
688
  Limits: a job runs up to 900 s (`timeoutSeconds`, default 300); 3 jobs per app and client run at once (429
@@ -622,6 +717,118 @@ await app.schedules.enable('nightly'); // or disable('nightly'); both resolve
622
717
  - Schedules are a setting reviewed like scopes. They stop when the app's release is withdrawn, functions are turned off for
623
718
  it, or a version without the schedule is approved.
624
719
 
720
+ ### Events
721
+
722
+ `events` in `hawk-app.json` name a job function the platform starts when something happens for a client, so the app
723
+ doesn't poll for it.
724
+
725
+ ```jsonc
726
+ "functions": { "on-event": { "job": true } },
727
+ "events": {
728
+ "client.added": { "function": "on-event" },
729
+ "config.changed": { "function": "on-event" },
730
+ "version.released": { "function": "on-event" }
731
+ }
732
+ ```
733
+
734
+ ```ts
735
+ // functions/on-event.ts
736
+ import type { HawkAppEvent, HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
737
+
738
+ export const handler: HawkFunction<HawkAppEvent> = async (input, ctx) => {
739
+ switch (input.event) {
740
+ case 'client.added': // { clientId, at }
741
+ await ctx.platform.storage.put('settings', { digest: 'weekly' });
742
+ break;
743
+ case 'config.changed': // { clientId, at, key, scope: 'client' | 'global' }; new values in ctx.config
744
+ break;
745
+ case 'version.released': // { clientId, at, version, previousVersion }; runs on the new version
746
+ break;
747
+ }
748
+ };
749
+ ```
750
+
751
+ | Event | When |
752
+ |---|---|
753
+ | `client.added` | The client's users see the app now and didn't before: a store request approved, the client added to the app's list, the app released (for every client already on its list), or an alias approved for the client. Only for listed clients: an app with visibility `all` has no list of who got it. |
754
+ | `config.changed` | A config value was set or cleared for the client, or globally (then for every client without its own value for the key). |
755
+ | `version.released` | Another version reached a client that had the app: a release or rollback, or its alias moved. |
756
+
757
+ - For an app every client has (visibility `all`), `config.changed` (global) and `version.released` go to the clients
758
+ whose users opened it in the last 90 days.
759
+ - The run is a job (`ctx.trigger` `{ type: 'event', event }`) for that client, as the user `event:<name>` (no roles,
760
+ not internal), **without a site or HawkSearch connection**: no user's session is behind it, so HawkSearch calls
761
+ answer 403 `hawksearch_not_available`. Storage, data, email, config and secrets work.
762
+ - Delivered once; not retried when it can't start (3 jobs already running for the client). Events are a setting
763
+ reviewed like scopes; only approved handlers run, and only while functions are on for the app.
764
+
765
+ #### HawkSearch events
766
+
767
+ `hawksearch.changed` (something changed in HawkSearch's dashboard) and `index.rebuilt` (an index rebuild finished) come
768
+ from HawkSearch itself, read once a minute for the engines where the client's users opened the app. They run **with
769
+ that engine and the client's HawkSearch connection**, so the handler can read what changed:
770
+
771
+ ```jsonc
772
+ "events": {
773
+ "hawksearch.changed": { "function": "sync-synonyms", "sections": ["synonym"] },
774
+ "index.rebuilt": { "function": "after-rebuild" }
775
+ }
776
+ ```
777
+
778
+ ```ts
779
+ import type { HawkFunction, HawkSearchChangedEvent } from '@bridgeline-digital/hawk-app-bridge/server';
780
+
781
+ export const handler: HawkFunction<HawkSearchChangedEvent> = async (input, ctx) => {
782
+ // { engineId: '3141', section: 'synonym', changes: [{ action: 'update', recordId: 12, apiAction: false, at }], count: 1, truncated: false }
783
+ const synonyms = await ctx.platform.dashboard.get('Synonym');
784
+ // …push them to the other system
785
+ };
786
+ ```
787
+
788
+ - `sections` (optional) are HawkSearch's section codes: `synonym`, `redirect`, `landingpage`, `field`, `facet`,
789
+ `visibilityrule`, `boostburyrule`, `pinneditem`, `contentitem`, `bannergroup`, `systemparameters` and the rest of the
790
+ search settings (the JSON schema lists them all). One event per section per minute, with up to 100 changes.
791
+ - `index.rebuilt`: `{ engineId, notificationId, status: 'success' | 'error', finishedAt }`, however the rebuild started.
792
+ - Expect them 1–2 minutes after the change. Changes your app makes itself through the platform (dashboard writes,
793
+ pinning, system parameters) aren't sent back to it. Who made a change is never sent. A handler can see an event twice
794
+ after a platform hiccup: make it idempotent.
795
+
796
+ ### Client API
797
+
798
+ `api` in `hawk-app.json` turns functions into endpoints the **client's own systems** call (a developer's script, a job
799
+ on their side), with their engine's HawkSearch site API key. No keys to manage: Bridgeline turns API access on per client
800
+ in HawkSearch (the app's kebab menu › API access), which also shows each endpoint's URL and a curl.
801
+
802
+ ```jsonc
803
+ "functions": { "design-html": {} },
804
+ "api": {
805
+ "design-html": { "function": "design-html", "method": "GET", "description": "A saved design's email HTML: ?designId=<id>" }
806
+ }
807
+ ```
808
+
809
+ ```ts
810
+ // functions/design-html.ts
811
+ import { apiResponse, type HawkApiResponse, type HawkFunction } from '@bridgeline-digital/hawk-app-bridge/server';
812
+
813
+ export const handler: HawkFunction<{ designId?: string }, HawkApiResponse> = async (input, ctx) => {
814
+ const saved = await ctx.platform.storage.get<Design>(`design#${input.designId}`);
815
+ if (!saved) return apiResponse('No design with this id.', { status: 404 });
816
+ return apiResponse(await renderDesign(saved.value, ctx), { contentType: 'text/html; charset=utf-8' });
817
+ };
818
+ ```
819
+
820
+ ```
821
+ curl -H "X-HawkSearch-ApiKey: <site API key>" "https://<platform>/v1/clients/<clientId>/apps/<slug>/api/design-html?designId=abc"
822
+ ```
823
+
824
+ - The input is the query string (GET, the default; values are strings) or the JSON body (POST).
825
+ - A function's answer is sent as JSON; `apiResponse(body, { contentType?, status? })` sends a body as is (HTML, CSV, …).
826
+ - The function isn't a job (the caller waits). `ctx.trigger` is `{ type: 'api', endpoint }`; it runs as the user
827
+ `api:<name>` (no roles, not internal) for the client, on the key's engine, with the key as its HawkSearch connection.
828
+ - At most 10 endpoints; 120 calls a minute per client (429). Endpoints are a setting reviewed like functions. Anyone
829
+ holding the client's site key can call them once access is on: answer only what the client's users could see in
830
+ the app.
831
+
625
832
  ## Email: `email:send`
626
833
 
627
834
  Send an email from the app or from a [function](#functions-functionsinvoke): a report to the user, an alert, a
@@ -695,8 +902,9 @@ await app.hawksearch.autocomplete({ Keyword: 'boo', ProductCount: 5 });
695
902
  ```
696
903
 
697
904
  - 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.
905
+ `MaxPerPage`, `SortBy`, `FacetSelections`, `CustomUrl`, `IndexName`, `SmartBar`, …). Every field of HawkSearch's search
906
+ API definition is typed (`SortingSetCode`, `PageId`, `RequestType`, `BoostQueries`, …). The platform sets `ClientGuid`
907
+ to the current engine's; you never need it. The response has the common fields typed; the rest is HawkSearch's as is.
700
908
  - `IsInPreview: true` runs on the engine's preview search API.
701
909
  - At most 600 calls per app and client per minute: a `429 too_many_requests` carries `retryAfterMs` (not retried
702
910
  for you). For bulk checks, run a few at a time.
@@ -713,6 +921,87 @@ await app.hawksearch.autocomplete({ Keyword: 'boo', ProductCount: 5 });
713
921
 
714
922
  Other HawkSearch errors (e.g. `400` for an unknown `IndexName`) come back as HawkSearch sent them.
715
923
 
924
+ ### Preview like HawkSearch's preview page
925
+
926
+ A preview search takes everything the preview page sets: `IsInPreview: true`, `SmartBar` (`PreviewDate` and switches
927
+ such as `BoostAndBury: false`, `ItemPin: false`), `ClientData: { PreviewBuckets: [ids] }` for visitor targets,
928
+ `IndexName`, `Is100CoverageTurnedOn`, and `ExplainDocId` to explain one result's score (`DocExplain` in the answer). The page's own pickers and explain panel are `app.hawksearch.preview` (scope **`hawksearch:read`**,
929
+ within the same per-minute limit):
930
+
931
+ ```ts
932
+ const settings = await app.hawksearch.preview.settings(); // visitor targets, fields, layouts
933
+ const { ClientIndexes, CurrentIndex } = await app.hawksearch.preview.indexes();
934
+ const item = await app.hawksearch.preview.item('SKU-123'); // one item as indexed ({ indexName } optional)
935
+ const why = await app.hawksearch.preview.explain({ Keyword: 'jackets', IsInPreview: true });
936
+ why.AdjustedKeyword; why.AppliedKeywordReplacementRules; why.ElasticQuery;
937
+ ```
938
+
939
+ `explain` needs the session's site API key (`403 preview_needs_site_key` with a group key).
940
+
941
+ **Pin items** (scope **`hawksearch:write`**): a search's response has `ItemPinId`, the Item Pin rule it applied (null
942
+ when none), and `IsPin` on the results that rule pins. Pin, unpin and reorder its items; HawkSearch saves the change as
943
+ the user, and it applies to live search too:
944
+
945
+ ```ts
946
+ const results = await app.hawksearch.search({ Keyword: 'jacket', IsInPreview: true });
947
+ if (results.ItemPinId) {
948
+ await app.hawksearch.pinning.set(results.ItemPinId, 'SKU-123', true); // false unpins
949
+ await app.hawksearch.pinning.order(results.ItemPinId, ['SKU-9', 'SKU-123']); // the pinned order
950
+ }
951
+ ```
952
+
953
+ Pass `{ previewDate }` (the search's `SmartBar.PreviewDate`) so the next preview search shows the change at once.
954
+ Site keys only, within the write limit, never retried. Errors: `pinning_needs_user` (403: the host didn't send the
955
+ user's HawkSearch admin), `hawksearch_write_needs_site_key` (403), `pinning_refused` (502: HawkSearch refused the
956
+ user), `pinning_not_configured` (503). A search without a rule has nothing to pin into: rules are created in HawkSearch.
957
+
958
+ ## HawkAI agents and smart responses: `hawkai:read`
959
+
960
+ The client's HawkAI agents and smart responses for the user's current engine, as Spark lists them: names and settings,
961
+ never prompts. From the app or a function (`ctx.platform.hawkai`).
962
+
963
+ ```ts
964
+ const agents = await app.hawkai.agents(); // by name
965
+ const responses = await app.hawkai.smartResponses(); // with maxItems and allowRequestOverride
966
+ const one = await app.hawkai.smartResponse(responses[0].id);
967
+ ```
968
+
969
+ Each is `{ id, name, description, model, allEngines, maxItems, allowRequestOverride }` (`allEngines`: set up for every
970
+ engine of the client).
971
+
972
+ **Run a smart response** for a query and a search, live or in preview, as the storefront does. The platform adds the
973
+ client, the engine's ClientGuid and its search API; HawkAI runs the search and picks the items:
974
+
975
+ ```ts
976
+ const answer = await app.hawkai.runSmartResponse(id, {
977
+ query: 'waterproof jacket',
978
+ search: { Keyword: 'jacket', IsInPreview: true, SmartBar: { PreviewDate: '2026-11-27T00:00:00' } },
979
+ });
980
+ answer.recommendation; // Markdown
981
+ answer.Results; // [{ DocId, Document, reason }]
982
+ ```
983
+
984
+ Answered whole (not streamed), within the search limit. **An assistant** runs in HawkAI's widget
985
+ ([`@bridgeline-digital/hawkai-assistant`](https://www.npmjs.com/package/@bridgeline-digital/hawkai-assistant)), which
986
+ talks to HawkAI from the browser: `app.hawkai.assistant(agentId)` gives it `{ apiUrl, accountId, agentId }`.
987
+
988
+ ```ts
989
+ HawkAI.Assistant.initialize({ ...(await app.hawkai.assistant(agentId)), ui: { display: 'embedded', embedHostSelector: '#assistant' } });
990
+ ```
991
+
992
+ List HawkAI's origin in `externalOrigins` (`https://assistant-dev.hawksearch.net` on dev, `https://assistant.hawksearch.net`
993
+ on prod). The widget initializes once per page: reload to switch agents.
994
+
995
+ | `error.code` | Status | Meaning |
996
+ |---|---|---|
997
+ | `scope_missing` | 403 | Add `hawkai:read` to `scopes`. |
998
+ | `engine_required` | 400 | The host session has no engine. |
999
+ | `hawkai_not_found` | 404 | No agent / smart response with that id for this client and engine. |
1000
+ | `hawkai_unavailable` | 502 | HawkAI's configuration couldn't be read, or HawkAI didn't answer in time: try again. |
1001
+ | `hawkai_error` | 502 | HawkAI answered without a smart response (its reason is in the message). |
1002
+ | `hawkai_limit_reached` | 429 | HawkAI's own limit for the client (tokens or rate). |
1003
+ | `too_many_requests` | 429 | Over the search limit (smart responses count as searches); wait `retryAfterMs`. |
1004
+
716
1005
  ## HawkSearch system parameters: `hawksearch:sysparams:write`
717
1006
 
718
1007
  Let users change a few HawkSearch **system parameters** of the engine they are working on (Sites → System Parameters
@@ -851,8 +1140,10 @@ await app.hawksearch.index.setCurrent(name);
851
1140
  - **Never the live index:** `deleteIndex` of the current index is 409 `index_is_current`. Engines may have only two
852
1141
  indexes: delete the old one before `create`. `unset-current`, index templates and group indexing aren't available.
853
1142
  - `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.
1143
+ ([Indexing API](https://developerdocs.hawksearch.com/reference/indexv2_get)) and resolves to its answer. The body is
1144
+ typed from HawkSearch's Indexing API definition (`IndexingRequests`, fields in PascalCase: `IndexName`, `Items`,
1145
+ `Hierarchies`, …; HawkSearch reads any case). Engines without an Indexing API answer 503 `indexing_api_not_available`;
1146
+ anything outside the list is 404.
856
1147
 
857
1148
  ## HawkSearch recommendations: `hawksearch:recommendations`
858
1149
 
@@ -1144,6 +1435,69 @@ await app.navigate({ intent: 'app', slug: 'other-tool' }); // also: 'report', 's
1144
1435
  await app.resize(); // when autoResize is off
1145
1436
  ```
1146
1437
 
1438
+ ### Say what the page shows: "Explain this page"
1439
+
1440
+ Hosts show an **Explain this page** button by the app: a short plain-language summary of what the page shows, what
1441
+ stands out and what to do, written by the platform's AI from what the app says is on screen. Every app gets it, with no
1442
+ scope and no AI budget of its own; the button shows only while the app has said what its page shows:
1443
+
1444
+ ```tsx
1445
+ // With the UI kit: sent shortly after it changes, cleared when the page unmounts; null while loading.
1446
+ usePageContext(rows ? { page: 'Zero-result searches', dateRange: context.dateRange, total, top10: rows.slice(0, 10) } : null);
1447
+
1448
+ // Without it:
1449
+ await app.assistant.setContext({ page: 'Zero-result searches', dateRange, total, top10 }); // null clears it
1450
+ ```
1451
+
1452
+ - **What to put in it:** the figures on screen, in plain names and numbers, with what they mean (`{ keyword, searches,
1453
+ results }`, not `{ k, s, r }`), and the page's name. A JSON object of at most 50 KB: summarize long tables (the top
1454
+ rows and the totals).
1455
+ - **Keep it current:** set it again when the user changes a filter or a tab; the answer is about what you last set. The
1456
+ host clears it when the app closes.
1457
+ - **The same context** goes to the host's assistant for apps with the `assistant` scope (below).
1458
+ - **The explanation** is written by the platform (Claude, the fast tier) from the context, the app's name and
1459
+ description, the page's title (`app.setTitle`) and the user's language. It's told to use only what the page says;
1460
+ users can copy it and rate it. Explanations come from one daily budget per client, for all its apps.
1461
+ - `hawk-app validate` warns when the app never sets a context, and `hawk-app dev` has the button in its bar.
1462
+
1463
+ Hosts: `<hawk-app-explain for="<frame id>">` (registered with `<hawk-app-frame>`) is the button and its side panel;
1464
+ the frame's `pageContext`, `hawk-page-context` event and `explainPage()` are there for hosts that build their own. See
1465
+ [HOSTS.md](HOSTS.md).
1466
+
1467
+ ### Export a page as a PDF
1468
+
1469
+ Pages worth keeping (a report, a summary for a meeting) can offer **Export as PDF**: while the page allows it, the
1470
+ host shows a PDF icon by the app, and a click downloads the app's whole page (its full height, not just what's on
1471
+ screen) as a PDF. Only the app's frame is in it, never the host's page. You choose the pages; no scope is needed.
1472
+
1473
+ ```tsx
1474
+ // With the UI kit: on while the page is mounted (false while it loads), off when it unmounts.
1475
+ usePdfExport(rows ? { fileName: `Zero results ${dateRange.from}`, title: 'Zero-result searches' } : false);
1476
+
1477
+ // Without it:
1478
+ await app.setPdfExport({ fileName: 'Zero results' }); // the icon shows
1479
+ await app.setPdfExport(null); // and hides
1480
+ ```
1481
+
1482
+ ```tsx
1483
+ <Stack direction="row" data-hawk-pdf-ignore> {/* left out of the PDF: filters, buttons */}
1484
+ ```
1485
+
1486
+ - **What's in it:** the page as the user sees it, rendered to images in the app (the bridge loads `html-to-image`
1487
+ only when someone exports) and cut into A4 pages, landscape for pages wider than 900 px. Text in the PDF is an
1488
+ image: it can't be selected or searched.
1489
+ - **Options:** `fileName` (without `.pdf`; default `<slug>-<yyyy-mm-dd>`), `title` (the PDF's properties; default the
1490
+ page's title).
1491
+ - **Leave out** what doesn't belong on paper with `data-hawk-pdf-ignore` (buttons, filters, the pager). Content inside
1492
+ a scrolling box (a table with its own scrollbar) is exported as far as it's scrolled into view: let it grow on pages
1493
+ you export.
1494
+ - Images from other sites that don't allow it (CORS) come out blank. A failed export shows the host's error toast and
1495
+ is reported like the page's other errors (`hawk-app errors`).
1496
+ - `hawk-app dev` has the icon in its toolbar. Errors (`HawkBridgeError`): `unsupported` (a host without PDF export).
1497
+
1498
+ Hosts: `<hawk-app-pdf for="<frame id>">` (registered with `<hawk-app-frame>`) is the icon; the frame's `pdfExport`,
1499
+ `hawk-pdf-export` event and `exportPdf()` are there for hosts that build their own. See [HOSTS.md](HOSTS.md).
1500
+
1147
1501
  ### Ask the host's assistant: `assistant`
1148
1502
 
1149
1503
  With the `assistant` scope, an app asks the host's assistant (HawkSearch: Hawk Assistant) a question as the user. The
@@ -1159,10 +1513,9 @@ useEffect(() => app.onContextChange((c) => setReady(!!c.features?.assistant)), [
1159
1513
  </Button>
1160
1514
  ```
1161
1515
 
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:
1516
+ The page context ([above](#say-what-the-page-shows-explain-this-page): `app.assistant.setContext`, or the UI kit's
1517
+ `usePageContext`) goes to the assistant too, for apps with the scope: with every question, the app's and the ones the
1518
+ user types, until the app replaces or clears it. Keep the questions themselves plain:
1166
1519
 
1167
1520
  ```ts
1168
1521
  useEffect(() => void app.assistant.setContext({ page: 'Recommendation Dashboard', range, totals, widgets }), [range, totals, widgets]);
@@ -1170,7 +1523,7 @@ await app.assistant.ask('Which widgets are taking up space?');
1170
1523
  ```
1171
1524
 
1172
1525
  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
1526
+ `invalid_request`; `setContext` needs no scope (without it, only "Explain this page" gets the context). 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
1527
  answers it itself calls `preventDefault()` and sets the element's `assistant` attribute. Otherwise the element sends the
1175
1528
  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
1529
  `features.assistant`) when it loads.
@@ -1264,9 +1617,12 @@ Which clients have the app is not in this file: manage them in the Hawk Analytic
1264
1617
  | `hosting` | `static` (the default): built files on the apps CDN. `server` is reserved for server-rendered apps and not accepted yet. |
1265
1618
  | `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
1619
  | `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). |
1620
+ | `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), `hawkai:read` for [HawkAI](#hawkai-agents-and-smart-responses-hawkairead), `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
1621
  | `functions` | Your [functions](#functions-functionsinvoke) and their settings: `{ "<name>": { "job", "timeoutSeconds", "memoryMb", "secrets" } }`. Recorded with each version. |
1269
1622
  | `schedules` | [Schedules](#schedules) for job functions: `{ "<name>": { "function", "cron", "timezone" } }`, at most 5, at most hourly. Needs `functions:invoke`. Reviewed like scopes. |
1623
+ | `events` | [Events](#events): a job function per event (`client.added`, `config.changed`, `version.released`, `hawksearch.changed`, `index.rebuilt`), `{ "<event>": { "function" } }` (`hawksearch.changed` also takes `sections`). Needs `functions:invoke`. Reviewed like scopes. |
1624
+ | `actions` | [Actions](#actions-start-the-app-from-hawksearchs-screens): buttons in HawkSearch's pages that open a view in a dialog, `{ "<name>": { "label", "view", "targets": { "hawksearch": [{ "path", "selector", "anchor"?, "position"?, "params" }] } } }` (at most 10). Always reviewed by an approver. |
1625
+ | `api` | [Client API](#client-api): endpoints the client's systems call with their HawkSearch site API key, `{ "<name>": { "function", "method"?: "GET" \| "POST", "description"? } }`, each a function that isn't a job (at most 10). Needs `functions:invoke`. Reviewed like scopes. |
1270
1626
  | `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
1627
  | `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
1628
  | `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"`. |
@@ -1287,8 +1643,8 @@ HawkSearch. See [versions, statuses and aliases](../../docs/versions-and-aliases
1287
1643
  - Client changes (Hawk Analytics portal or `npx hawk-app clients`) apply at once, without a deploy or approval, and
1288
1644
  are recorded in the app's history.
1289
1645
  - 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.
1646
+ - Apps using queries, functions, system parameters, dashboard writes, AI, external Lambdas, HawkSearch features, index
1647
+ rebuilds or [actions](#actions-start-the-app-from-hawksearchs-screens) are never auto-approved.
1292
1648
 
1293
1649
  **Approval.** Members of the Hawk-App-Developers group submit a draft, new version or settings change for approval in HawkSearch; members of
1294
1650
  the Hawk-App-Approvers group approve, reject (with a reason) or force approve it. A new deploy or settings change clears a
@@ -3,7 +3,7 @@
3
3
  "slug": "showcase",
4
4
  "name": "Hawk Apps Showcase",
5
5
  "icon": "icon.svg",
6
- "description": "Every Hawk Apps feature in one app: context, analytics, dashboard API, search, queries, functions, jobs, system parameters, storage, app config, AI and host messages.",
6
+ "description": "Every Hawk Apps feature in one app: context, analytics, dashboard API, search, preview tools, HawkAI, queries, functions, jobs, system parameters, storage, app config, AI and host messages.",
7
7
  "category": "Showcase",
8
8
  "version": "0.1.0",
9
9
  "visibility": "internal",
@@ -21,7 +21,8 @@
21
21
  "hawksearch:index",
22
22
  "hawksearch:index:items",
23
23
  "ai:invoke",
24
- "storage"
24
+ "storage",
25
+ "hawkai:read"
25
26
  ],
26
27
  "systemParameters": [
27
28
  "Timezone",
@@ -41,7 +42,11 @@
41
42
  "label": "Notification recipients"
42
43
  }
43
44
  },
44
- "externalOrigins": [],
45
+ "externalOrigins": [
46
+ "https://cdn.jsdelivr.net",
47
+ "https://assistant-dev.hawksearch.net",
48
+ "https://assistant.hawksearch.net"
49
+ ],
45
50
  "functions": {
46
51
  "echo": {
47
52
  "timeoutSeconds": 10,