@proveanything/smartlinks 1.6.6 → 1.6.7

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.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.6.6 | Generated: 2026-03-03T14:13:59.227Z
3
+ Version: 1.6.7 | Generated: 2026-03-03T14:18:48.483Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -26,113 +26,112 @@ Complete guide to registering and discovering navigable states in SmartLinks app
26
26
 
27
27
  ## Overview
28
28
 
29
- SmartLinks apps can expose **deep-linkable states** — specific views, pages, or configurations that other apps, the portal, or AI orchestrators can navigate to directly without loading the app first.
29
+ SmartLinks apps expose **deep-linkable states** — specific views, pages, or configurations that the portal, AI orchestrators, or other apps can navigate to directly without loading the app first.
30
30
 
31
- This is done by publishing a `linkable` array in the app's `appConfig`. Any consumer (portal shell, AI agent, another app) can read this array to discover what navigable states the app currently offers.
31
+ Deep-linkable states come from **two sources** depending on their nature:
32
+
33
+ | Source | What goes here | When it changes |
34
+ |--------|---------------|-----------------|
35
+ | **App manifest** (`app.admin.json`) | Fixed routes built into the app | Only when the app itself is updated |
36
+ | **App config** (`appConfig.linkable`) | Content-driven entries that vary by collection | When admins create, remove, or rename content |
37
+
38
+ Consumers merge both sources to get the full set of navigable states for an app.
32
39
 
33
40
  ```text
34
- ┌─────────────────────────────────────────────────────────────────────┐
35
- Your SmartLinks App
36
-
37
- ┌───────────────────────┐ writes ┌────────────────────────┐│
38
- │ Admin / Data Events │ ─────────────► appConfig.linkable ││
39
- └───────────────────────┘ └────────────────────────┘│
40
- └─────────────────────────────────────────────────────────────────────┘
41
-
42
- reads linkable[]
43
-
44
- ┌───────────────────────────────────┼───────────────────────┐
45
- ▼ ▼ ▼
46
- ┌─────────────────────┐ ┌────────────────────┐ ┌─────────────────────┐
47
- │ Portal Nav Menu │ │ AI Orchestrator │ │ Another App │
48
- (sidebar / drawer) │ │ ("Go to About Us")│ │ (cross-app nav) │
49
- └─────────────────────┘ └────────────────────┘ └─────────────────────┘
41
+ ┌─────────────────────────────────────────────────────────────────────────┐
42
+ App Definition (build time)
43
+ app.admin.json → "linkable": [
44
+ { "title": "Gallery", "path": "/gallery" }, │
45
+ { "title": "Settings", "path": "/settings" }
46
+ ] │
47
+ └──────────────────────────────────────┬──────────────────────────────────┘
48
+ static (always present)
49
+
50
+ ┌────────────────────────┐
51
+ │ merged linkable[] │◄──── dynamic (per collection)
52
+ └────────────────────────┘
53
+
54
+ ┌──────────────────────────────────────┴──────────────────────────────────┐
55
+ App Config (runtime, per collection)
56
+ │ appConfig.linkable = [ │
57
+ │ { "title": "About Us", "params": { "pageId": "about-us" } }, │
58
+ │ { "title": "FAQ", "params": { "pageId": "faq" } } │
59
+ │ ] │
60
+ └─────────────────────────────────────────────────────────────────────────┘
50
61
  ```
51
62
 
52
63
  ### Key Benefits
53
64
 
54
65
  - ✅ **Discoverable** — consumers don't need to know the app's internal routing
55
- - ✅ **Decoupled** — the app owns the registry; consumers just read it
56
- - ✅ **Dynamic** — the registry updates automatically when content changes
66
+ - ✅ **No first-run initialisation** — static routes are available from the moment the app is installed
67
+ - ✅ **Dynamic** — content-driven entries update automatically when data changes
57
68
  - ✅ **AI-friendly** — machine-readable titles make states naturally addressable by LLMs
58
- - ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API
69
+ - ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API and the app manifest
59
70
 
60
71
  ---
61
72
 
62
- ## Quick Start
73
+ ## Two Sources of Truth
63
74
 
64
- **App sidepublish linkable states:**
75
+ ### Static Links App Manifest
65
76
 
66
- ```typescript
67
- import { appConfiguration } from '@proveanything/smartlinks';
77
+ Static links are routes that are **built into the app itself** — they exist regardless of what content admins have created. They belong in `app.admin.json` as a top-level `linkable` array:
68
78
 
69
- await appConfiguration.setConfig({
70
- collectionId,
71
- appId,
72
- config: {
73
- // ... other app config ...
74
- linkable: [
75
- { title: 'Gallery', path: '/gallery' },
76
- { title: 'About Us', params: { pageId: 'about-us' } },
77
- { title: 'FAQ', params: { pageId: 'faq' } },
78
- ]
79
- },
80
- admin: true
81
- });
79
+ ```json
80
+ {
81
+ "linkable": [
82
+ { "title": "Gallery", "path": "/gallery" },
83
+ { "title": "Settings", "path": "/settings" },
84
+ { "title": "Advanced Settings", "path": "/settings", "params": { "tab": "advanced" } }
85
+ ],
86
+ "setup": { }
87
+ }
82
88
  ```
83
89
 
84
- **Consumer side — read linkable states:**
85
-
86
- ```typescript
87
- import { appConfiguration } from '@proveanything/smartlinks';
88
-
89
- const config = await appConfiguration.getConfig({ collectionId, appId });
90
- const linkable = config?.linkable ?? [];
90
+ **Use this for:**
91
+ - Named sections or tabs that are always present in the app
92
+ - Admin-accessible pages that exist at fixed routes
93
+ - Any navigable state whose existence doesn't depend on content
91
94
 
92
- // linkable = [
93
- // { title: 'Gallery', path: '/gallery' },
94
- // { title: 'About Us', params: { pageId: 'about-us' } },
95
- // { title: 'FAQ', params: { pageId: 'faq' } },
96
- // ]
97
- ```
95
+ **Why manifest, not config?**
96
+ These routes are part of the app's *definition*, not its *runtime state*. Putting them in the manifest means they're immediately available the moment the app is installed — no first-run initialisation, no sync step, no risk of them being missing if the app has never been launched.
98
97
 
99
98
  ---
100
99
 
101
- ## The `linkable` Registry
102
-
103
- Each app **MAY** store a `linkable` array at the top level of its `appConfig`. This array describes all the deep-linkable states the app currently exposes.
100
+ ### Dynamic Links — App Config
104
101
 
105
- ### Storage Location
106
-
107
- The `linkable` key is **reserved** at the top level of `appConfig` for this purpose. Do not use it for anything else.
102
+ Dynamic links are entries **generated from content** — CMS pages, product guides, FAQ items, or anything else that admins create and manage. These are stored at `appConfig.linkable` and updated whenever the content changes:
108
103
 
109
104
  ```typescript
110
- // Writing
105
+ import { appConfiguration } from '@proveanything/smartlinks';
106
+
111
107
  await appConfiguration.setConfig({
112
108
  collectionId,
113
109
  appId,
114
110
  config: {
115
111
  // ... other app config ...
116
- linkable: [ /* DeepLinkEntry[] */ ]
112
+ linkable: [
113
+ { title: 'About Us', params: { pageId: 'about-us' } },
114
+ { title: 'Care Guide', params: { pageId: 'care-guide' } },
115
+ { title: 'FAQ', params: { pageId: 'faq' } },
116
+ ]
117
117
  },
118
118
  admin: true
119
119
  });
120
-
121
- // Reading
122
- const config = await appConfiguration.getConfig({ collectionId, appId });
123
- const linkable: DeepLinkEntry[] = config?.linkable ?? [];
124
120
  ```
125
121
 
126
- ### Admin vs Public Access
122
+ **Use this for:**
123
+ - Pages or content entries created by admins that should be directly navigable
124
+ - Any set of linkable states that varies between collections or changes over time
127
125
 
128
- - **Writing** always requires `admin: true` only the app itself (running with admin credentials) should update its own registry.
129
- - **Reading** can be done publicly — portals and consumers typically read the registry without admin credentials.
126
+ The `linkable` key is **reserved** at the top level of `appConfig` for this purpose.
127
+
128
+ > **`appConfig.linkable` is for dynamic content only.** Static routes belong in the manifest. Don't write fixed routes to `appConfig` on first install just to make them discoverable — that's the problem this split is designed to avoid.
130
129
 
131
130
  ---
132
131
 
133
132
  ## Schema: DeepLinkEntry
134
133
 
135
- Each entry in the `linkable` array describes one navigable state:
134
+ Both sources use the same entry shape:
136
135
 
137
136
  ```typescript
138
137
  interface DeepLinkEntry {
@@ -159,65 +158,63 @@ interface DeepLinkEntry {
159
158
  }
160
159
  ```
161
160
 
162
- ### Field Notes
163
-
164
161
  | Field | Required | Notes |
165
162
  |-------|----------|-------|
166
163
  | `title` | ✅ | Displayed in menus and offered to AI agents. Should be concise and human-readable. |
167
164
  | `path` | ❌ | Hash route within the app. Defaults to `/` if omitted. |
168
165
  | `params` | ❌ | App-specific query params only. Platform params are injected automatically. |
169
166
 
170
- > **Tip:** An entry with only a `title` (no `path` or `params`) is valid it navigates to the app's default view. This is rarely useful unless the app's home screen is itself a meaningful destination distinct from other entries.
167
+ > **Tip:** Different entries can share the same `path` if they differ by `params` — for example, two entries pointing at `/settings` with different `tab` params.
171
168
 
172
169
  ---
173
170
 
174
- ## Entry Types
175
-
176
- ### 1. Data-Driven Entries (Dynamic)
171
+ ## Quick Start
177
172
 
178
- Generated from app data e.g., CMS pages, product guides, FAQ items. These are typically re-synced whenever the underlying data changes.
173
+ **An app with both static routes and dynamic content pages:**
179
174
 
175
+ `app.admin.json` — declare static routes once, at build time:
180
176
  ```json
181
- [
182
- { "title": "About Us", "params": { "pageId": "about-us" } },
183
- { "title": "Care Guide", "params": { "pageId": "care-guide" } },
184
- { "title": "FAQ", "params": { "pageId": "faq" } }
185
- ]
177
+ {
178
+ "linkable": [
179
+ { "title": "Gallery", "path": "/gallery" },
180
+ { "title": "Settings", "path": "/settings" }
181
+ ]
182
+ }
186
183
  ```
187
184
 
188
- **When to use:** The set of linkable states depends on admin-created content. The registry is rebuilt whenever pages are added, removed, published, or renamed.
189
-
190
- ### 2. Route-Based Entries (Static)
185
+ App code sync dynamic content entries whenever pages change:
186
+ ```typescript
187
+ import { appConfiguration } from '@proveanything/smartlinks';
191
188
 
192
- Fixed routes that are built into the app. Declared once and rarely change.
189
+ async function syncDynamicLinks(collectionId: string, appId: string): Promise<void> {
190
+ // Always fetch fresh data — never rely on cache
191
+ const pages = await myApi.getPublishedPages({ forceRefresh: true });
192
+ const entries = pages
193
+ .filter(p => p.deepLinkable)
194
+ .map(p => ({ title: p.title, params: { pageId: p.slug } }));
193
195
 
194
- ```json
195
- [
196
- { "title": "Gallery", "path": "/gallery" },
197
- { "title": "Settings", "path": "/settings" },
198
- { "title": "Advanced Settings", "path": "/settings", "params": { "tab": "advanced" } }
199
- ]
196
+ const config = await appConfiguration.getConfig({ collectionId, appId, admin: true }) ?? {};
197
+ await appConfiguration.setConfig({
198
+ collectionId, appId,
199
+ config: { ...config, linkable: entries },
200
+ admin: true,
201
+ });
202
+ }
200
203
  ```
201
204
 
202
- **When to use:** The app has multiple built-in views (tabs, sections) that should always be directly navigable, regardless of content.
203
-
204
- > **Note:** Different entries can share the same `path` if they differ by `params` as in the Settings example above.
205
-
206
- ### 3. Mixed
205
+ Consumer merge both sources:
206
+ ```typescript
207
+ // staticLinks come from the app's manifest (loaded by the platform when the app was registered)
208
+ const staticLinks: DeepLinkEntry[] = appManifest?.linkable ?? [];
207
209
 
208
- Apps can freely combine both patterns. Static routes can appear alongside dynamic content entries:
210
+ // dynamicLinks come from appConfig (per-collection, fetched at runtime)
211
+ const config = await appConfiguration.getConfig({ collectionId, appId });
212
+ const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
209
213
 
210
- ```json
211
- [
212
- { "title": "Gallery", "path": "/gallery" },
213
- { "title": "About Us", "params": { "pageId": "about-us" } },
214
- { "title": "FAQ", "params": { "pageId": "faq" } },
215
- { "title": "Settings", "path": "/settings" }
216
- ]
214
+ // Full set — static (structural) first, then dynamic (content)
215
+ const allLinks = [...staticLinks, ...dynamicLinks];
217
216
  ```
218
217
 
219
- **Ordering:** Entries are displayed in array order. Place the most commonly navigated states first.
220
-
221
218
  ---
222
219
 
223
220
  ## URL Resolution
@@ -254,16 +251,17 @@ The platform automatically injects the following — **never include these in `p
254
251
 
255
252
  ---
256
253
 
257
- ## Syncing the Registry
254
+ ## Syncing Dynamic Links
255
+
256
+ This section only applies to **dynamic links stored in `appConfig.linkable`**. Static links declared in the manifest require no syncing.
258
257
 
259
258
  ### When to Sync
260
259
 
261
- Apps **MUST** update the `linkable` array whenever the set of navigable states changes:
260
+ Apps **MUST** update `appConfig.linkable` whenever the set of dynamic navigable states changes:
262
261
 
263
262
  - A page is created, deleted, published, or unpublished
264
263
  - A page's `deepLinkable` flag is toggled
265
264
  - A page title changes
266
- - App routes are added or removed
267
265
 
268
266
  ### How to Sync
269
267
 
@@ -273,9 +271,9 @@ The sync pattern is a **full replace** — read current config, overwrite `linka
273
271
  import { appConfiguration } from '@proveanything/smartlinks';
274
272
 
275
273
  async function syncLinkable(collectionId: string, appId: string): Promise<void> {
276
- // 1. Fetch the current set of linkable states from your data source
277
- // Always fetch fresh — never rely on a local cache
278
- const entries = await fetchLinkableEntries(); // your app-specific logic
274
+ // 1. Fetch the current set of dynamic entries from your data source.
275
+ // Always fetch fresh — never rely on a local cache.
276
+ const entries = await fetchDynamicLinkEntries(); // app-specific
279
277
 
280
278
  // 2. Read the existing appConfig to preserve other keys
281
279
  let config: Record<string, unknown> = {};
@@ -289,7 +287,7 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
289
287
  // Config may not exist yet on first run — that's fine
290
288
  }
291
289
 
292
- // 3. Merge and save
290
+ // 3. Overwrite only the linkable key and save
293
291
  await appConfiguration.setConfig({
294
292
  collectionId,
295
293
  appId,
@@ -299,42 +297,13 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
299
297
  }
300
298
  ```
301
299
 
302
- ### What `fetchLinkableEntries` looks like
303
-
304
- This function is app-specific. A typical CMS app might look like:
305
-
306
- ```typescript
307
- async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
308
- // Fetch your pages/content from the server (not from cache)
309
- const pages = await myApi.getPublishedPages({ forceRefresh: true });
310
-
311
- return pages
312
- .filter(page => page.deepLinkable)
313
- .map(page => ({
314
- title: page.title,
315
- params: { pageId: page.slug },
316
- }));
317
- }
318
- ```
319
-
320
- A route-based app simply returns a static array:
321
-
322
- ```typescript
323
- async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
324
- return [
325
- { title: 'Gallery', path: '/gallery' },
326
- { title: 'Settings', path: '/settings' },
327
- ];
328
- }
329
- ```
330
-
331
300
  ### Idempotency
332
301
 
333
302
  The sync is always a full replace of the `linkable` array. Running it multiple times with the same data produces the same result — no duplicates, no orphans.
334
303
 
335
- ### Clearing the Registry
304
+ ### Clearing Dynamic Links
336
305
 
337
- To remove all linkable states (e.g., when an app is deactivated):
306
+ To remove all dynamic entries (e.g., all content was unpublished):
338
307
 
339
308
  ```typescript
340
309
  await appConfiguration.setConfig({
@@ -345,13 +314,48 @@ await appConfiguration.setConfig({
345
314
  });
346
315
  ```
347
316
 
317
+ Note that clearing `appConfig.linkable` only removes dynamic entries. Static links declared in the manifest are unaffected.
318
+
348
319
  ---
349
320
 
350
321
  ## Consumer Patterns
351
322
 
323
+ ### Merging Both Sources
324
+
325
+ Consumers must always merge static (manifest) and dynamic (appConfig) entries. The platform provides the manifest when the app is registered; the consumer fetches the config at runtime:
326
+
327
+ ```typescript
328
+ import { appConfiguration } from '@proveanything/smartlinks';
329
+
330
+ interface ResolvedLink extends DeepLinkEntry {
331
+ appId: string;
332
+ source: 'manifest' | 'config';
333
+ }
334
+
335
+ async function getAppLinks(
336
+ collectionId: string,
337
+ appId: string,
338
+ appManifest: { linkable?: DeepLinkEntry[] }
339
+ ): Promise<ResolvedLink[]> {
340
+ // Static entries from the manifest — always present, no network call needed
341
+ const staticLinks = (appManifest.linkable ?? []).map(e => ({
342
+ ...e, appId, source: 'manifest' as const
343
+ }));
344
+
345
+ // Dynamic entries from appConfig — vary per collection
346
+ const config = await appConfiguration.getConfig({ collectionId, appId });
347
+ const dynamicLinks = (config?.linkable ?? []).map(e => ({
348
+ ...e, appId, source: 'config' as const
349
+ }));
350
+
351
+ // Static first (fixed structure), then dynamic (content entries)
352
+ return [...staticLinks, ...dynamicLinks];
353
+ }
354
+ ```
355
+
352
356
  ### Portal Navigation Menu
353
357
 
354
- A portal shell can aggregate linkable states from all apps in a collection to build a unified navigation menu:
358
+ A portal shell aggregates links from all installed apps to build a unified navigation menu:
355
359
 
356
360
  ```typescript
357
361
  import { appConfiguration } from '@proveanything/smartlinks';
@@ -363,15 +367,22 @@ interface NavLink {
363
367
  params?: Record<string, string>;
364
368
  }
365
369
 
366
- async function buildNavMenu(collectionId: string, appIds: string[]): Promise<NavLink[]> {
370
+ async function buildNavMenu(
371
+ collectionId: string,
372
+ apps: Array<{ appId: string; manifest: { linkable?: DeepLinkEntry[] } }>
373
+ ): Promise<NavLink[]> {
367
374
  const links: NavLink[] = [];
368
375
 
369
- for (const appId of appIds) {
376
+ for (const { appId, manifest } of apps) {
377
+ // Static links from manifest
378
+ const staticLinks = manifest.linkable ?? [];
379
+
380
+ // Dynamic links from appConfig
370
381
  const config = await appConfiguration.getConfig({ collectionId, appId });
371
- const entries: DeepLinkEntry[] = config?.linkable ?? [];
382
+ const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
372
383
 
373
384
  links.push(
374
- ...entries.map(entry => ({ ...entry, appId }))
385
+ ...[...staticLinks, ...dynamicLinks].map(entry => ({ ...entry, appId }))
375
386
  );
376
387
  }
377
388
 
@@ -381,19 +392,22 @@ async function buildNavMenu(collectionId: string, appIds: string[]): Promise<Nav
381
392
 
382
393
  ### AI Orchestration
383
394
 
384
- An AI agent can discover what states an app exposes and offer navigation as part of a conversation:
395
+ An AI agent discovers all navigable states and offers them to the user:
385
396
 
386
397
  ```typescript
387
398
  import { appConfiguration } from '@proveanything/smartlinks';
388
399
 
389
- async function getNavigationOptions(collectionId: string, appId: string) {
400
+ async function getNavigationOptions(
401
+ collectionId: string,
402
+ appId: string,
403
+ appManifest: { linkable?: DeepLinkEntry[] }
404
+ ): Promise<string[]> {
405
+ const staticLinks = appManifest.linkable ?? [];
390
406
  const config = await appConfiguration.getConfig({ collectionId, appId });
391
- const linkable: DeepLinkEntry[] = config?.linkable ?? [];
407
+ const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
392
408
 
393
- // The AI now knows what's available:
394
- // "I can navigate you to: Gallery, About Us, or FAQ.
395
- // Which would you prefer?"
396
- return linkable.map(entry => entry.title);
409
+ // "I can navigate you to: Gallery, Settings, About Us, or FAQ."
410
+ return [...staticLinks, ...dynamicLinks].map(entry => entry.title);
397
411
  }
398
412
  ```
399
413
 
@@ -401,16 +415,15 @@ The `title` field is intentionally human-readable so it can be injected directly
401
415
 
402
416
  ### Cross-App Navigation
403
417
 
404
- An app can use another app's `linkable` registry to construct a `NavigationRequest` for the portal's `onNavigate` callback:
418
+ An app constructs a `NavigationRequest` using the target app's merged links:
405
419
 
406
420
  ```typescript
407
- // Inside a widget or container's onNavigate handler
408
- const targetConfig = await appConfiguration.getConfig({
409
- collectionId,
410
- appId: 'target-app-id',
411
- });
421
+ // Search static links first, then dynamic
422
+ const staticEntry = targetManifest?.linkable?.find(e => e.title === 'Gallery');
423
+ const config = await appConfiguration.getConfig({ collectionId, appId: 'target-app-id' });
424
+ const dynamicEntry = config?.linkable?.find(e => e.title === 'About Us');
412
425
 
413
- const entry = targetConfig?.linkable?.find(e => e.title === 'About Us');
426
+ const entry = staticEntry ?? dynamicEntry;
414
427
 
415
428
  if (entry) {
416
429
  onNavigate({
@@ -423,51 +436,12 @@ if (entry) {
423
436
 
424
437
  ---
425
438
 
426
- ## Manifest Declaration
427
-
428
- Apps that support deep linking **SHOULD** declare the `linkable` key in their `app.admin.json` config schema. This signals to platform tooling that the key is auto-managed and prevents accidental manual edits:
429
-
430
- ```json
431
- {
432
- "setup": {
433
- "configSchema": {
434
- "properties": {
435
- "linkable": {
436
- "type": "array",
437
- "description": "Auto-managed deep link registry. Do not edit manually.",
438
- "readOnly": true,
439
- "items": {
440
- "type": "object",
441
- "required": ["title"],
442
- "properties": {
443
- "title": { "type": "string", "description": "Human-readable label" },
444
- "path": { "type": "string", "description": "Hash route path, e.g. '/gallery'" },
445
- "params": {
446
- "type": "object",
447
- "description": "App-specific query params. Do not include platform context params.",
448
- "additionalProperties": { "type": "string" }
449
- }
450
- }
451
- }
452
- }
453
- }
454
- }
455
- }
456
- }
457
- ```
458
-
459
- > **Note:** The `readOnly: true` flag is a hint to the admin UI to hide or disable this field in manual config editors.
460
-
461
- ---
462
-
463
439
  ## TypeScript Types
464
440
 
465
- Copy these types into your app, or import them if they become part of the SDK's public API:
466
-
467
441
  ```typescript
468
442
  /**
469
443
  * A single navigable state exposed by a SmartLinks app.
470
- * Stored as an array at `appConfig.linkable`.
444
+ * Used in both app.admin.json `linkable` (static) and appConfig `linkable` (dynamic).
471
445
  */
472
446
  export interface DeepLinkEntry {
473
447
  /** Human-readable label shown in menus and offered to AI agents */
@@ -485,10 +459,7 @@ export interface DeepLinkEntry {
485
459
  params?: Record<string, string>;
486
460
  }
487
461
 
488
- /**
489
- * The shape of the `linkable` key in appConfig.
490
- * Alias for convenience.
491
- */
462
+ /** Convenience alias for an array of DeepLinkEntry */
492
463
  export type DeepLinkRegistry = DeepLinkEntry[];
493
464
  ```
494
465
 
@@ -498,17 +469,20 @@ export type DeepLinkRegistry = DeepLinkEntry[];
498
469
 
499
470
  ### Rules
500
471
 
501
- 1. **`linkable` is reserved** — do not use this key for other purposes in `appConfig`.
502
- 2. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
503
- 3. **`title` is required** — every entry must have a human-readable label.
504
- 4. **Sync from server** always fetch fresh data when syncing; never read from a local cache or previously fetched state.
505
- 5. **Full replace** — always overwrite the entire `linkable` array; never attempt to diff or patch individual entries.
472
+ 1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.admin.json`. Do not write it to `appConfig` on first run.
473
+ 2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
474
+ 3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
475
+ 4. **No platform context params in entries** `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
476
+ 5. **`title` is required** — every entry must have a human-readable label.
477
+ 6. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
478
+ 7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
479
+ 8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
506
480
 
507
481
  ### Best Practices
508
482
 
509
483
  - **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
510
- - **Order entries by relevance** — the first few entries are most likely to be shown in compact menus; place the most important states first.
511
- - **Sync eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
512
- - **Handle missing `linkable` gracefully** — older apps won't have a `linkable` key. Always use `config?.linkable ?? []` rather than `config.linkable`.
513
- - **Don't duplicate the default route** — if your app has only one view, an empty `linkable` array (or omitting it) is better than a single entry that just opens the app.
514
- - **Test URL resolution** — confirm that your entries produce the correct URLs in the platform before publishing. Check that params are correctly appended and context params are not doubled up.
484
+ - **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
485
+ - **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
486
+ - **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
487
+ - **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
488
+ - **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.6.6 | Generated: 2026-03-03T14:13:59.227Z
3
+ Version: 1.6.7 | Generated: 2026-03-03T14:18:48.483Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -26,113 +26,112 @@ Complete guide to registering and discovering navigable states in SmartLinks app
26
26
 
27
27
  ## Overview
28
28
 
29
- SmartLinks apps can expose **deep-linkable states** — specific views, pages, or configurations that other apps, the portal, or AI orchestrators can navigate to directly without loading the app first.
29
+ SmartLinks apps expose **deep-linkable states** — specific views, pages, or configurations that the portal, AI orchestrators, or other apps can navigate to directly without loading the app first.
30
30
 
31
- This is done by publishing a `linkable` array in the app's `appConfig`. Any consumer (portal shell, AI agent, another app) can read this array to discover what navigable states the app currently offers.
31
+ Deep-linkable states come from **two sources** depending on their nature:
32
+
33
+ | Source | What goes here | When it changes |
34
+ |--------|---------------|-----------------|
35
+ | **App manifest** (`app.admin.json`) | Fixed routes built into the app | Only when the app itself is updated |
36
+ | **App config** (`appConfig.linkable`) | Content-driven entries that vary by collection | When admins create, remove, or rename content |
37
+
38
+ Consumers merge both sources to get the full set of navigable states for an app.
32
39
 
33
40
  ```text
34
- ┌─────────────────────────────────────────────────────────────────────┐
35
- Your SmartLinks App
36
-
37
- ┌───────────────────────┐ writes ┌────────────────────────┐│
38
- │ Admin / Data Events │ ─────────────► appConfig.linkable ││
39
- └───────────────────────┘ └────────────────────────┘│
40
- └─────────────────────────────────────────────────────────────────────┘
41
-
42
- reads linkable[]
43
-
44
- ┌───────────────────────────────────┼───────────────────────┐
45
- ▼ ▼ ▼
46
- ┌─────────────────────┐ ┌────────────────────┐ ┌─────────────────────┐
47
- │ Portal Nav Menu │ │ AI Orchestrator │ │ Another App │
48
- (sidebar / drawer) │ │ ("Go to About Us")│ │ (cross-app nav) │
49
- └─────────────────────┘ └────────────────────┘ └─────────────────────┘
41
+ ┌─────────────────────────────────────────────────────────────────────────┐
42
+ App Definition (build time)
43
+ app.admin.json → "linkable": [
44
+ { "title": "Gallery", "path": "/gallery" }, │
45
+ { "title": "Settings", "path": "/settings" }
46
+ ] │
47
+ └──────────────────────────────────────┬──────────────────────────────────┘
48
+ static (always present)
49
+
50
+ ┌────────────────────────┐
51
+ │ merged linkable[] │◄──── dynamic (per collection)
52
+ └────────────────────────┘
53
+
54
+ ┌──────────────────────────────────────┴──────────────────────────────────┐
55
+ App Config (runtime, per collection)
56
+ │ appConfig.linkable = [ │
57
+ │ { "title": "About Us", "params": { "pageId": "about-us" } }, │
58
+ │ { "title": "FAQ", "params": { "pageId": "faq" } } │
59
+ │ ] │
60
+ └─────────────────────────────────────────────────────────────────────────┘
50
61
  ```
51
62
 
52
63
  ### Key Benefits
53
64
 
54
65
  - ✅ **Discoverable** — consumers don't need to know the app's internal routing
55
- - ✅ **Decoupled** — the app owns the registry; consumers just read it
56
- - ✅ **Dynamic** — the registry updates automatically when content changes
66
+ - ✅ **No first-run initialisation** — static routes are available from the moment the app is installed
67
+ - ✅ **Dynamic** — content-driven entries update automatically when data changes
57
68
  - ✅ **AI-friendly** — machine-readable titles make states naturally addressable by LLMs
58
- - ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API
69
+ - ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API and the app manifest
59
70
 
60
71
  ---
61
72
 
62
- ## Quick Start
73
+ ## Two Sources of Truth
63
74
 
64
- **App sidepublish linkable states:**
75
+ ### Static Links App Manifest
65
76
 
66
- ```typescript
67
- import { appConfiguration } from '@proveanything/smartlinks';
77
+ Static links are routes that are **built into the app itself** — they exist regardless of what content admins have created. They belong in `app.admin.json` as a top-level `linkable` array:
68
78
 
69
- await appConfiguration.setConfig({
70
- collectionId,
71
- appId,
72
- config: {
73
- // ... other app config ...
74
- linkable: [
75
- { title: 'Gallery', path: '/gallery' },
76
- { title: 'About Us', params: { pageId: 'about-us' } },
77
- { title: 'FAQ', params: { pageId: 'faq' } },
78
- ]
79
- },
80
- admin: true
81
- });
79
+ ```json
80
+ {
81
+ "linkable": [
82
+ { "title": "Gallery", "path": "/gallery" },
83
+ { "title": "Settings", "path": "/settings" },
84
+ { "title": "Advanced Settings", "path": "/settings", "params": { "tab": "advanced" } }
85
+ ],
86
+ "setup": { }
87
+ }
82
88
  ```
83
89
 
84
- **Consumer side — read linkable states:**
85
-
86
- ```typescript
87
- import { appConfiguration } from '@proveanything/smartlinks';
88
-
89
- const config = await appConfiguration.getConfig({ collectionId, appId });
90
- const linkable = config?.linkable ?? [];
90
+ **Use this for:**
91
+ - Named sections or tabs that are always present in the app
92
+ - Admin-accessible pages that exist at fixed routes
93
+ - Any navigable state whose existence doesn't depend on content
91
94
 
92
- // linkable = [
93
- // { title: 'Gallery', path: '/gallery' },
94
- // { title: 'About Us', params: { pageId: 'about-us' } },
95
- // { title: 'FAQ', params: { pageId: 'faq' } },
96
- // ]
97
- ```
95
+ **Why manifest, not config?**
96
+ These routes are part of the app's *definition*, not its *runtime state*. Putting them in the manifest means they're immediately available the moment the app is installed — no first-run initialisation, no sync step, no risk of them being missing if the app has never been launched.
98
97
 
99
98
  ---
100
99
 
101
- ## The `linkable` Registry
102
-
103
- Each app **MAY** store a `linkable` array at the top level of its `appConfig`. This array describes all the deep-linkable states the app currently exposes.
100
+ ### Dynamic Links — App Config
104
101
 
105
- ### Storage Location
106
-
107
- The `linkable` key is **reserved** at the top level of `appConfig` for this purpose. Do not use it for anything else.
102
+ Dynamic links are entries **generated from content** — CMS pages, product guides, FAQ items, or anything else that admins create and manage. These are stored at `appConfig.linkable` and updated whenever the content changes:
108
103
 
109
104
  ```typescript
110
- // Writing
105
+ import { appConfiguration } from '@proveanything/smartlinks';
106
+
111
107
  await appConfiguration.setConfig({
112
108
  collectionId,
113
109
  appId,
114
110
  config: {
115
111
  // ... other app config ...
116
- linkable: [ /* DeepLinkEntry[] */ ]
112
+ linkable: [
113
+ { title: 'About Us', params: { pageId: 'about-us' } },
114
+ { title: 'Care Guide', params: { pageId: 'care-guide' } },
115
+ { title: 'FAQ', params: { pageId: 'faq' } },
116
+ ]
117
117
  },
118
118
  admin: true
119
119
  });
120
-
121
- // Reading
122
- const config = await appConfiguration.getConfig({ collectionId, appId });
123
- const linkable: DeepLinkEntry[] = config?.linkable ?? [];
124
120
  ```
125
121
 
126
- ### Admin vs Public Access
122
+ **Use this for:**
123
+ - Pages or content entries created by admins that should be directly navigable
124
+ - Any set of linkable states that varies between collections or changes over time
127
125
 
128
- - **Writing** always requires `admin: true` only the app itself (running with admin credentials) should update its own registry.
129
- - **Reading** can be done publicly — portals and consumers typically read the registry without admin credentials.
126
+ The `linkable` key is **reserved** at the top level of `appConfig` for this purpose.
127
+
128
+ > **`appConfig.linkable` is for dynamic content only.** Static routes belong in the manifest. Don't write fixed routes to `appConfig` on first install just to make them discoverable — that's the problem this split is designed to avoid.
130
129
 
131
130
  ---
132
131
 
133
132
  ## Schema: DeepLinkEntry
134
133
 
135
- Each entry in the `linkable` array describes one navigable state:
134
+ Both sources use the same entry shape:
136
135
 
137
136
  ```typescript
138
137
  interface DeepLinkEntry {
@@ -159,65 +158,63 @@ interface DeepLinkEntry {
159
158
  }
160
159
  ```
161
160
 
162
- ### Field Notes
163
-
164
161
  | Field | Required | Notes |
165
162
  |-------|----------|-------|
166
163
  | `title` | ✅ | Displayed in menus and offered to AI agents. Should be concise and human-readable. |
167
164
  | `path` | ❌ | Hash route within the app. Defaults to `/` if omitted. |
168
165
  | `params` | ❌ | App-specific query params only. Platform params are injected automatically. |
169
166
 
170
- > **Tip:** An entry with only a `title` (no `path` or `params`) is valid it navigates to the app's default view. This is rarely useful unless the app's home screen is itself a meaningful destination distinct from other entries.
167
+ > **Tip:** Different entries can share the same `path` if they differ by `params` — for example, two entries pointing at `/settings` with different `tab` params.
171
168
 
172
169
  ---
173
170
 
174
- ## Entry Types
175
-
176
- ### 1. Data-Driven Entries (Dynamic)
171
+ ## Quick Start
177
172
 
178
- Generated from app data e.g., CMS pages, product guides, FAQ items. These are typically re-synced whenever the underlying data changes.
173
+ **An app with both static routes and dynamic content pages:**
179
174
 
175
+ `app.admin.json` — declare static routes once, at build time:
180
176
  ```json
181
- [
182
- { "title": "About Us", "params": { "pageId": "about-us" } },
183
- { "title": "Care Guide", "params": { "pageId": "care-guide" } },
184
- { "title": "FAQ", "params": { "pageId": "faq" } }
185
- ]
177
+ {
178
+ "linkable": [
179
+ { "title": "Gallery", "path": "/gallery" },
180
+ { "title": "Settings", "path": "/settings" }
181
+ ]
182
+ }
186
183
  ```
187
184
 
188
- **When to use:** The set of linkable states depends on admin-created content. The registry is rebuilt whenever pages are added, removed, published, or renamed.
189
-
190
- ### 2. Route-Based Entries (Static)
185
+ App code sync dynamic content entries whenever pages change:
186
+ ```typescript
187
+ import { appConfiguration } from '@proveanything/smartlinks';
191
188
 
192
- Fixed routes that are built into the app. Declared once and rarely change.
189
+ async function syncDynamicLinks(collectionId: string, appId: string): Promise<void> {
190
+ // Always fetch fresh data — never rely on cache
191
+ const pages = await myApi.getPublishedPages({ forceRefresh: true });
192
+ const entries = pages
193
+ .filter(p => p.deepLinkable)
194
+ .map(p => ({ title: p.title, params: { pageId: p.slug } }));
193
195
 
194
- ```json
195
- [
196
- { "title": "Gallery", "path": "/gallery" },
197
- { "title": "Settings", "path": "/settings" },
198
- { "title": "Advanced Settings", "path": "/settings", "params": { "tab": "advanced" } }
199
- ]
196
+ const config = await appConfiguration.getConfig({ collectionId, appId, admin: true }) ?? {};
197
+ await appConfiguration.setConfig({
198
+ collectionId, appId,
199
+ config: { ...config, linkable: entries },
200
+ admin: true,
201
+ });
202
+ }
200
203
  ```
201
204
 
202
- **When to use:** The app has multiple built-in views (tabs, sections) that should always be directly navigable, regardless of content.
203
-
204
- > **Note:** Different entries can share the same `path` if they differ by `params` as in the Settings example above.
205
-
206
- ### 3. Mixed
205
+ Consumer merge both sources:
206
+ ```typescript
207
+ // staticLinks come from the app's manifest (loaded by the platform when the app was registered)
208
+ const staticLinks: DeepLinkEntry[] = appManifest?.linkable ?? [];
207
209
 
208
- Apps can freely combine both patterns. Static routes can appear alongside dynamic content entries:
210
+ // dynamicLinks come from appConfig (per-collection, fetched at runtime)
211
+ const config = await appConfiguration.getConfig({ collectionId, appId });
212
+ const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
209
213
 
210
- ```json
211
- [
212
- { "title": "Gallery", "path": "/gallery" },
213
- { "title": "About Us", "params": { "pageId": "about-us" } },
214
- { "title": "FAQ", "params": { "pageId": "faq" } },
215
- { "title": "Settings", "path": "/settings" }
216
- ]
214
+ // Full set — static (structural) first, then dynamic (content)
215
+ const allLinks = [...staticLinks, ...dynamicLinks];
217
216
  ```
218
217
 
219
- **Ordering:** Entries are displayed in array order. Place the most commonly navigated states first.
220
-
221
218
  ---
222
219
 
223
220
  ## URL Resolution
@@ -254,16 +251,17 @@ The platform automatically injects the following — **never include these in `p
254
251
 
255
252
  ---
256
253
 
257
- ## Syncing the Registry
254
+ ## Syncing Dynamic Links
255
+
256
+ This section only applies to **dynamic links stored in `appConfig.linkable`**. Static links declared in the manifest require no syncing.
258
257
 
259
258
  ### When to Sync
260
259
 
261
- Apps **MUST** update the `linkable` array whenever the set of navigable states changes:
260
+ Apps **MUST** update `appConfig.linkable` whenever the set of dynamic navigable states changes:
262
261
 
263
262
  - A page is created, deleted, published, or unpublished
264
263
  - A page's `deepLinkable` flag is toggled
265
264
  - A page title changes
266
- - App routes are added or removed
267
265
 
268
266
  ### How to Sync
269
267
 
@@ -273,9 +271,9 @@ The sync pattern is a **full replace** — read current config, overwrite `linka
273
271
  import { appConfiguration } from '@proveanything/smartlinks';
274
272
 
275
273
  async function syncLinkable(collectionId: string, appId: string): Promise<void> {
276
- // 1. Fetch the current set of linkable states from your data source
277
- // Always fetch fresh — never rely on a local cache
278
- const entries = await fetchLinkableEntries(); // your app-specific logic
274
+ // 1. Fetch the current set of dynamic entries from your data source.
275
+ // Always fetch fresh — never rely on a local cache.
276
+ const entries = await fetchDynamicLinkEntries(); // app-specific
279
277
 
280
278
  // 2. Read the existing appConfig to preserve other keys
281
279
  let config: Record<string, unknown> = {};
@@ -289,7 +287,7 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
289
287
  // Config may not exist yet on first run — that's fine
290
288
  }
291
289
 
292
- // 3. Merge and save
290
+ // 3. Overwrite only the linkable key and save
293
291
  await appConfiguration.setConfig({
294
292
  collectionId,
295
293
  appId,
@@ -299,42 +297,13 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
299
297
  }
300
298
  ```
301
299
 
302
- ### What `fetchLinkableEntries` looks like
303
-
304
- This function is app-specific. A typical CMS app might look like:
305
-
306
- ```typescript
307
- async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
308
- // Fetch your pages/content from the server (not from cache)
309
- const pages = await myApi.getPublishedPages({ forceRefresh: true });
310
-
311
- return pages
312
- .filter(page => page.deepLinkable)
313
- .map(page => ({
314
- title: page.title,
315
- params: { pageId: page.slug },
316
- }));
317
- }
318
- ```
319
-
320
- A route-based app simply returns a static array:
321
-
322
- ```typescript
323
- async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
324
- return [
325
- { title: 'Gallery', path: '/gallery' },
326
- { title: 'Settings', path: '/settings' },
327
- ];
328
- }
329
- ```
330
-
331
300
  ### Idempotency
332
301
 
333
302
  The sync is always a full replace of the `linkable` array. Running it multiple times with the same data produces the same result — no duplicates, no orphans.
334
303
 
335
- ### Clearing the Registry
304
+ ### Clearing Dynamic Links
336
305
 
337
- To remove all linkable states (e.g., when an app is deactivated):
306
+ To remove all dynamic entries (e.g., all content was unpublished):
338
307
 
339
308
  ```typescript
340
309
  await appConfiguration.setConfig({
@@ -345,13 +314,48 @@ await appConfiguration.setConfig({
345
314
  });
346
315
  ```
347
316
 
317
+ Note that clearing `appConfig.linkable` only removes dynamic entries. Static links declared in the manifest are unaffected.
318
+
348
319
  ---
349
320
 
350
321
  ## Consumer Patterns
351
322
 
323
+ ### Merging Both Sources
324
+
325
+ Consumers must always merge static (manifest) and dynamic (appConfig) entries. The platform provides the manifest when the app is registered; the consumer fetches the config at runtime:
326
+
327
+ ```typescript
328
+ import { appConfiguration } from '@proveanything/smartlinks';
329
+
330
+ interface ResolvedLink extends DeepLinkEntry {
331
+ appId: string;
332
+ source: 'manifest' | 'config';
333
+ }
334
+
335
+ async function getAppLinks(
336
+ collectionId: string,
337
+ appId: string,
338
+ appManifest: { linkable?: DeepLinkEntry[] }
339
+ ): Promise<ResolvedLink[]> {
340
+ // Static entries from the manifest — always present, no network call needed
341
+ const staticLinks = (appManifest.linkable ?? []).map(e => ({
342
+ ...e, appId, source: 'manifest' as const
343
+ }));
344
+
345
+ // Dynamic entries from appConfig — vary per collection
346
+ const config = await appConfiguration.getConfig({ collectionId, appId });
347
+ const dynamicLinks = (config?.linkable ?? []).map(e => ({
348
+ ...e, appId, source: 'config' as const
349
+ }));
350
+
351
+ // Static first (fixed structure), then dynamic (content entries)
352
+ return [...staticLinks, ...dynamicLinks];
353
+ }
354
+ ```
355
+
352
356
  ### Portal Navigation Menu
353
357
 
354
- A portal shell can aggregate linkable states from all apps in a collection to build a unified navigation menu:
358
+ A portal shell aggregates links from all installed apps to build a unified navigation menu:
355
359
 
356
360
  ```typescript
357
361
  import { appConfiguration } from '@proveanything/smartlinks';
@@ -363,15 +367,22 @@ interface NavLink {
363
367
  params?: Record<string, string>;
364
368
  }
365
369
 
366
- async function buildNavMenu(collectionId: string, appIds: string[]): Promise<NavLink[]> {
370
+ async function buildNavMenu(
371
+ collectionId: string,
372
+ apps: Array<{ appId: string; manifest: { linkable?: DeepLinkEntry[] } }>
373
+ ): Promise<NavLink[]> {
367
374
  const links: NavLink[] = [];
368
375
 
369
- for (const appId of appIds) {
376
+ for (const { appId, manifest } of apps) {
377
+ // Static links from manifest
378
+ const staticLinks = manifest.linkable ?? [];
379
+
380
+ // Dynamic links from appConfig
370
381
  const config = await appConfiguration.getConfig({ collectionId, appId });
371
- const entries: DeepLinkEntry[] = config?.linkable ?? [];
382
+ const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
372
383
 
373
384
  links.push(
374
- ...entries.map(entry => ({ ...entry, appId }))
385
+ ...[...staticLinks, ...dynamicLinks].map(entry => ({ ...entry, appId }))
375
386
  );
376
387
  }
377
388
 
@@ -381,19 +392,22 @@ async function buildNavMenu(collectionId: string, appIds: string[]): Promise<Nav
381
392
 
382
393
  ### AI Orchestration
383
394
 
384
- An AI agent can discover what states an app exposes and offer navigation as part of a conversation:
395
+ An AI agent discovers all navigable states and offers them to the user:
385
396
 
386
397
  ```typescript
387
398
  import { appConfiguration } from '@proveanything/smartlinks';
388
399
 
389
- async function getNavigationOptions(collectionId: string, appId: string) {
400
+ async function getNavigationOptions(
401
+ collectionId: string,
402
+ appId: string,
403
+ appManifest: { linkable?: DeepLinkEntry[] }
404
+ ): Promise<string[]> {
405
+ const staticLinks = appManifest.linkable ?? [];
390
406
  const config = await appConfiguration.getConfig({ collectionId, appId });
391
- const linkable: DeepLinkEntry[] = config?.linkable ?? [];
407
+ const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
392
408
 
393
- // The AI now knows what's available:
394
- // "I can navigate you to: Gallery, About Us, or FAQ.
395
- // Which would you prefer?"
396
- return linkable.map(entry => entry.title);
409
+ // "I can navigate you to: Gallery, Settings, About Us, or FAQ."
410
+ return [...staticLinks, ...dynamicLinks].map(entry => entry.title);
397
411
  }
398
412
  ```
399
413
 
@@ -401,16 +415,15 @@ The `title` field is intentionally human-readable so it can be injected directly
401
415
 
402
416
  ### Cross-App Navigation
403
417
 
404
- An app can use another app's `linkable` registry to construct a `NavigationRequest` for the portal's `onNavigate` callback:
418
+ An app constructs a `NavigationRequest` using the target app's merged links:
405
419
 
406
420
  ```typescript
407
- // Inside a widget or container's onNavigate handler
408
- const targetConfig = await appConfiguration.getConfig({
409
- collectionId,
410
- appId: 'target-app-id',
411
- });
421
+ // Search static links first, then dynamic
422
+ const staticEntry = targetManifest?.linkable?.find(e => e.title === 'Gallery');
423
+ const config = await appConfiguration.getConfig({ collectionId, appId: 'target-app-id' });
424
+ const dynamicEntry = config?.linkable?.find(e => e.title === 'About Us');
412
425
 
413
- const entry = targetConfig?.linkable?.find(e => e.title === 'About Us');
426
+ const entry = staticEntry ?? dynamicEntry;
414
427
 
415
428
  if (entry) {
416
429
  onNavigate({
@@ -423,51 +436,12 @@ if (entry) {
423
436
 
424
437
  ---
425
438
 
426
- ## Manifest Declaration
427
-
428
- Apps that support deep linking **SHOULD** declare the `linkable` key in their `app.admin.json` config schema. This signals to platform tooling that the key is auto-managed and prevents accidental manual edits:
429
-
430
- ```json
431
- {
432
- "setup": {
433
- "configSchema": {
434
- "properties": {
435
- "linkable": {
436
- "type": "array",
437
- "description": "Auto-managed deep link registry. Do not edit manually.",
438
- "readOnly": true,
439
- "items": {
440
- "type": "object",
441
- "required": ["title"],
442
- "properties": {
443
- "title": { "type": "string", "description": "Human-readable label" },
444
- "path": { "type": "string", "description": "Hash route path, e.g. '/gallery'" },
445
- "params": {
446
- "type": "object",
447
- "description": "App-specific query params. Do not include platform context params.",
448
- "additionalProperties": { "type": "string" }
449
- }
450
- }
451
- }
452
- }
453
- }
454
- }
455
- }
456
- }
457
- ```
458
-
459
- > **Note:** The `readOnly: true` flag is a hint to the admin UI to hide or disable this field in manual config editors.
460
-
461
- ---
462
-
463
439
  ## TypeScript Types
464
440
 
465
- Copy these types into your app, or import them if they become part of the SDK's public API:
466
-
467
441
  ```typescript
468
442
  /**
469
443
  * A single navigable state exposed by a SmartLinks app.
470
- * Stored as an array at `appConfig.linkable`.
444
+ * Used in both app.admin.json `linkable` (static) and appConfig `linkable` (dynamic).
471
445
  */
472
446
  export interface DeepLinkEntry {
473
447
  /** Human-readable label shown in menus and offered to AI agents */
@@ -485,10 +459,7 @@ export interface DeepLinkEntry {
485
459
  params?: Record<string, string>;
486
460
  }
487
461
 
488
- /**
489
- * The shape of the `linkable` key in appConfig.
490
- * Alias for convenience.
491
- */
462
+ /** Convenience alias for an array of DeepLinkEntry */
492
463
  export type DeepLinkRegistry = DeepLinkEntry[];
493
464
  ```
494
465
 
@@ -498,17 +469,20 @@ export type DeepLinkRegistry = DeepLinkEntry[];
498
469
 
499
470
  ### Rules
500
471
 
501
- 1. **`linkable` is reserved** — do not use this key for other purposes in `appConfig`.
502
- 2. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
503
- 3. **`title` is required** — every entry must have a human-readable label.
504
- 4. **Sync from server** always fetch fresh data when syncing; never read from a local cache or previously fetched state.
505
- 5. **Full replace** — always overwrite the entire `linkable` array; never attempt to diff or patch individual entries.
472
+ 1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.admin.json`. Do not write it to `appConfig` on first run.
473
+ 2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
474
+ 3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
475
+ 4. **No platform context params in entries** `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
476
+ 5. **`title` is required** — every entry must have a human-readable label.
477
+ 6. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
478
+ 7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
479
+ 8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
506
480
 
507
481
  ### Best Practices
508
482
 
509
483
  - **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
510
- - **Order entries by relevance** — the first few entries are most likely to be shown in compact menus; place the most important states first.
511
- - **Sync eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
512
- - **Handle missing `linkable` gracefully** — older apps won't have a `linkable` key. Always use `config?.linkable ?? []` rather than `config.linkable`.
513
- - **Don't duplicate the default route** — if your app has only one view, an empty `linkable` array (or omitting it) is better than a single entry that just opens the app.
514
- - **Test URL resolution** — confirm that your entries produce the correct URLs in the platform before publishing. Check that params are correctly appended and context params are not doubled up.
484
+ - **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
485
+ - **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
486
+ - **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
487
+ - **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
488
+ - **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",