@hailer/mcp 1.3.14 → 1.3.23

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 (57) hide show
  1. package/.claude/skills/create-and-publish-app/SKILL.md +44 -56
  2. package/.claude/skills/hailer-app-builder/SKILL.md +414 -970
  3. package/.claude/skills/hailer-app-primitives/SKILL.md +742 -0
  4. package/.claude/skills/hailer-apps-pictures/SKILL.md +191 -87
  5. package/.claude/skills/hailer-design-patterns/SKILL.md +317 -0
  6. package/.claude/skills/hailer-design-system/SKILL.md +202 -149
  7. package/.claude/skills/hailer-workflow-archetypes/SKILL.md +301 -0
  8. package/.claude/skills/insight-join-patterns/SKILL.md +313 -0
  9. package/.claude/skills/publish-hailer-app/SKILL.md +211 -0
  10. package/.claude/skills/sdk-activity-patterns/SKILL.md +257 -105
  11. package/.claude/skills/sdk-function-fields/SKILL.md +253 -492
  12. package/.claude/skills/sdk-insight-calculations/SKILL.md +364 -0
  13. package/.claude/skills/sdk-insight-queries/SKILL.md +192 -397
  14. package/.claude/skills/sdk-ws-config-skill/SKILL.md +265 -720
  15. package/.claude/skills/tool-response-verification/SKILL.md +143 -0
  16. package/CLAUDE.md +50 -19
  17. package/dist/bot/bot.d.ts.map +1 -1
  18. package/dist/bot/bot.js +26 -34
  19. package/dist/bot/bot.js.map +1 -1
  20. package/dist/bot/services/helper-prompt.d.ts +1 -1
  21. package/dist/bot/services/helper-prompt.d.ts.map +1 -1
  22. package/dist/bot/services/helper-prompt.js +62 -82
  23. package/dist/bot/services/helper-prompt.js.map +1 -1
  24. package/dist/bot/services/system-prompt.js +4 -4
  25. package/dist/config.d.ts +1 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +6 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/mcp/hailer-rpc.d.ts +1 -0
  30. package/dist/mcp/hailer-rpc.d.ts.map +1 -1
  31. package/dist/mcp/hailer-rpc.js +4 -0
  32. package/dist/mcp/hailer-rpc.js.map +1 -1
  33. package/dist/mcp/tools/activity.d.ts.map +1 -1
  34. package/dist/mcp/tools/activity.js +8 -2
  35. package/dist/mcp/tools/activity.js.map +1 -1
  36. package/dist/mcp/tools/app-core.d.ts.map +1 -1
  37. package/dist/mcp/tools/app-core.js +6 -3
  38. package/dist/mcp/tools/app-core.js.map +1 -1
  39. package/dist/mcp/tools/app-marketplace.d.ts.map +1 -1
  40. package/dist/mcp/tools/app-marketplace.js +5 -1
  41. package/dist/mcp/tools/app-marketplace.js.map +1 -1
  42. package/dist/mcp/webhook-handler.d.ts.map +1 -1
  43. package/dist/mcp/webhook-handler.js +27 -0
  44. package/dist/mcp/webhook-handler.js.map +1 -1
  45. package/dist/public-chat/graduate.d.ts.map +1 -1
  46. package/dist/public-chat/graduate.js +153 -53
  47. package/dist/public-chat/graduate.js.map +1 -1
  48. package/dist/public-chat/rate-limit.js +1 -1
  49. package/dist/public-chat/rate-limit.js.map +1 -1
  50. package/dist/public-chat/studio-prewarm.js +2 -2
  51. package/dist/public-chat/studio-prewarm.js.map +1 -1
  52. package/dist/public-chat/system-prompt.d.ts.map +1 -1
  53. package/dist/public-chat/system-prompt.js +41 -25
  54. package/dist/public-chat/system-prompt.js.map +1 -1
  55. package/package.json +1 -1
  56. package/.claude/skills/hailer-project-protocol/SKILL.md +0 -398
  57. package/.opencode/package-lock.json +0 -117
@@ -1,60 +1,155 @@
1
1
  ---
2
2
  name: hailer-app-builder
3
- description: Patterns for building Hailer apps with @hailer/app-sdk
4
- version: 1.3.1
3
+ description: Core patterns for building Hailer apps with @hailer/app-sdk — load when building any app, reading activity data, writing field values, handling SDK versions, implementing real-time updates, or debugging common app errors
4
+ version: 3.12.0
5
5
  triggers:
6
6
  - build app
7
7
  - hailer app
8
8
  - app sdk
9
9
  ---
10
10
 
11
- # Hailer App Builder Skill
11
+ # Hailer App Builder
12
12
 
13
- Patterns and templates for building Hailer apps with @hailer/app-sdk.
13
+ Core patterns and templates for building Hailer apps with `@hailer/app-sdk`. For UI components and icons, load `hailer-design-system`. For scaffolding, publishing, and deploy, load `create-and-publish-app` or `publish-hailer-app`. For canonical UI primitives (ConfirmDialog, SearchableSelect, DatePicker, hailerRest), load `hailer-app-primitives`.
14
14
 
15
15
  <build-philosophy>
16
16
  ## How to Build Fast: Correct Data First, Theme Does the Looks
17
17
 
18
- The goal of the first version is **showing the correct data the user asked for** not a custom-designed UI. Get the data right; the user iterates on looks afterward. Two rules make apps both fast and good:
18
+ 1. **Spend your effort on the data layer.** Read real field/phase/workflow IDs from `workspace/enums.ts` + `fields.ts` (never guess, never use labels as keys). A dashboard showing the *wrong* numbers beautifully is a failure; correct numbers in plain components is a success.
19
19
 
20
- 1. **Spend your effort on the data layer, because that's what's easy to get wrong.** Read the real field/phase/workflow IDs from `workspace/enums.ts` + `fields.ts` (never guess, never use labels as keys), fetch with the SDK, and read field values by ID. A dashboard that shows the *wrong* numbers beautifully is a failure; correct numbers in plain components is a success.
21
-
22
- 2. **Do NOT hand-design UI or build a component library — the scaffold already installed the Hailer theme.** `main.tsx` wires `<ChakraProvider theme={hailerTheme}>`, so **every stock Chakra component is already on Hailer brand** (Nunito Sans, the full Hailer palette, light/dark via system) and there are **214 ready icons** in `src/hailer/theme/icons/`. Compose the UI from plain Chakra primitives — they look right for free:
23
- - Layout: `Box`, `Flex`, `Grid`, `SimpleGrid`, `Stack`, `Container`
24
- - Data: `Stat`/`StatLabel`/`StatNumber`/`StatHelpText` (stat cards), `Table`/`Thead`/`Tbody`/`Tr`/`Td`, `Card`/`CardHeader`/`CardBody`
20
+ 2. **Do NOT hand-design UI.** The scaffold wires `<ChakraProvider theme={hailerTheme}>` every stock Chakra component is already on Hailer brand. Compose from plain Chakra primitives:
21
+ - Layout: `Box`, `Flex`, `Grid`, `SimpleGrid`, `Stack`
22
+ - Data: `Stat`/`StatLabel`/`StatNumber`, `Table`/`Thead`/`Tbody`/`Tr`/`Td`, `Card`/`CardBody`
25
23
  - Text/state: `Heading`, `Text`, `Badge`, `Tag`, `Spinner`, `Skeleton`, `Alert`
26
- - Import icons like `import { HailerSearch } from './hailer/theme/icons/HailerSearch'`
27
24
 
28
- Don't write custom CSS, custom color values, or bespoke styled components on the first pass. Reach for a Chakra component; it's already themed. The user refines the look later your job is correct data in a clean themed shell.
25
+ 3. **Always handle three states** loading (`Spinner`/`Skeleton`), empty (a `Text`, never a crash), and outside-Hailer (the app must render standalone).
29
26
 
30
- 3. **Always handle the three states** loading (`Spinner`/`Skeleton`), empty (a `Text`, never a crash), and outside-Hailer (the app must render standalone never block on `inside`). Real data is async; assume it's missing first.
27
+ 4. **VERIFY data exists BEFORE building against it.** Check activity counts per phase for every workflow+phase you plan to fetch. After publishing, state what data the app should show so the user can confirm it matches.
28
+ </build-philosophy>
31
29
 
32
- 4. **The scaffold's package.json IS the dependency budget — do NOT `npm install` anything else.** The scaffold already ships everything a v1 app needs: `@chakra-ui/react` (+emotion) for UI, `chart.js` + `react-chartjs-2` for charts, `framer-motion` for animation AND drag gestures, `zustand` for state, `immer`, and `@hailer/app-sdk`. No dnd-kit, no other chart libs, no date libs, no component kits — external packages mean unreviewed bytes shipped into the workspace and a look that drifts off-theme. If the requested UX genuinely seems to need a new dependency, FIRST prefer the simpler themed version (e.g. a board moves items with buttons/menus + `hailer.activity.update`, or framer-motion drag), and if that truly won't do, ASK the user before installing anything.
30
+ <version-check>
31
+ ## SDK Version Check (FIRST STEP)
33
32
 
34
- 5. **VERIFY data exists BEFORE building against it, and AFTER publishing.** An app that renders its empty state on real data is NOT done — it's unverified. Before writing components: check activity counts per phase (MCP `list_activities` with `countOnly: true`, or `core_init`) for every workflow+phase you plan to fetch. If the phase you targeted is empty (e.g. zero matches in "Scheduled"), pick the phases that actually hold the data — don't guess from phase names. After publishing: state what data the app should show ("8 matches in Scheduled, 24 fans across 4 phases") so the user can confirm the live app matches. "Shipped, shows 'no data'" is a failed delivery unless the workspace genuinely has no data — and then say so explicitly.
35
- </build-philosophy>
33
+ **Before writing any code, check `package.json` for the `@hailer/app-sdk` version.** The 1.x and 2.x APIs differ in parameter names and return types.
36
34
 
37
- <critical-rules>
38
- ## CRITICAL: Scaffolding and Data Sources
35
+ ### Breaking Changes: v1 → v2
39
36
 
40
- **Scaffold with the exact non-interactive form, then prep it:**
41
- ```
42
- npx @hailer/create-app <name> --template react-ts
43
- npx hailer-mcp prep-app <name>
37
+ | Method | v1 (1.0.3) | v2 (2.0.0+) |
38
+ |--------|-----------|-----------------|
39
+ | `activity.create()` | `(processId, items[], opts?)` | `(workflowId, items[], opts?)` |
40
+ | `user.list()` return | `{[key: string]: User}` object map | `User[]` array |
41
+ | `app.config.update()` | `(appId, config)` | `(config, {appId?})` |
42
+ | `product.isProductInstalled()` | `(id)` | `(productId, workspaceId)` |
43
+ | `product.install()` | `(id)` | `(productId, workspaceId)` |
44
+ | `product.list()` return | `Product[]` | `{products[], totalCount}` |
45
+ | `permission.map()` | zero-arg `→ Promise<void>` | `({workspaceId?}?) → Promise<PermissionMap>` |
46
+
47
+ ### v2 Naming Inconsistency
48
+
49
+ v2 renamed `processId` → `workflowId` in some methods but not others:
50
+ - `activity.create(workflowId, ...)` — renamed
51
+ - `activity.list(processId, ...)` — NOT renamed
52
+ - `ui.activity.create(processId, ...)` — NOT renamed
53
+
54
+ ### User Type (v1 vs v2)
55
+
56
+ - v1: `{ _id, firstname, lastname, email, lastSeen, defaultProfilepic, status?, managedUser?, removed? }` — rich shape
57
+ - v2: `{ _id, firstname, lastname }` — minimal; profile metadata stripped
58
+
59
+ ### `Workflow.phases` is a map in ALL SDK versions
60
+
61
+ `workflow.phases` is `{[phaseId: string]: WorkflowPhase}` in both v1 and v2. Calling `.find()` on it silently returns `undefined` every time.
62
+
63
+ ```typescript
64
+ // WRONG — .find() silently returns undefined on a map
65
+ const phase = workflow.phases.find(p => p._id === phaseId);
66
+
67
+ // CORRECT — direct key lookup
68
+ const phase = workflow.phases?.[phaseId];
44
69
  ```
45
- The name is POSITIONAL (not `--name`), the template must be valid (`react-ts`/`react`/`vanilla-ts`/`svelte-ts`/…, NOT `minimal`), and the dir must be fresh — that combination runs with zero prompts. A bare `npx @hailer/create-app`, a wrong `--name` flag, an invalid template, or piping input all drop into an interactive prompt that hangs the agent. `prep-app` then clears the scaffold's placeholder appId + fake author and fixes its `StoreSet` tsc error, so you skip those papercuts entirely. Scaffold fresh, never copy an existing app, never hand-create the structure. (Apps are script-only — no MCP scaffold tool exists.)
46
70
 
47
- **For project data structure (workflows, fields, phases):**
48
- - READ workspace/ TypeScript files directly (fields.ts, phases.ts, enums.ts)
49
- - Do NOT use MCP tools for data structure queries
50
- - The SDK pull provides all needed type information locally
71
+ ### v2-Only Modules
72
+
73
+ Don't import in 1.x apps: `payment`, `admin`, `metrics`, `workflow.install()`, `ui.workflow.function.edit()`.
74
+
75
+ ### v2 Renamed Types
76
+
77
+ `HailerAppConfig` → `Config`, `HailerSettings` → `Settings`, `HailerSignal` → `Signal`, `HailerApiInfo` → `ApiInfo`
78
+
79
+ ### v2 Signal Changes
80
+
81
+ `subscribedSignals` constructor option removed — v2 streams ALL signals. Drop the option; filter by `signal.name` in the callback.
82
+
83
+ **v2 2.4.2 (runtime-verified):** activity signals use **dotted names** (`activity.update`, `activity.create`, `activity.delete`). Bare `'activity'` fires **0×**. Use prefix-match: `signal.name.startsWith('activity')`. Signal payload: `{ activityIds: string[], workflowId: string, phaseId: string }`.
84
+ </version-check>
85
+
86
+ <api-quick-index>
87
+ ## API Quick Index
88
+
89
+ | Task | Method | Notes |
90
+ |------|--------|-------|
91
+ | List activities | `api.activity.list(wfId, phaseId, { limit, skip, filters, sortBy, sortOrder, includeUsers?, includeTeams?, includeHistory? }?)` | `search` does NOT exist in `ActivityListOptions` — only in `ActivityKanbanListOptions` |
92
+ | Get one activity | `hailer.activity.get(activityId)` | Single arg in both v1 and v2 |
93
+ | Create activities | `hailer.activity.create(wfId, items[], opts?)` | opts optional |
94
+ | Update activities | `hailer.activity.update(items[], opts)` | Plain values, not {type,value} |
95
+ | Delete activities | `hailer.activity.remove(activityIds[])` | Returns count |
96
+ | Move to phase | `hailer.activity.update([{_id, phaseId}], {})` | No `.move()` method |
97
+ | Kanban view | `hailer.activity.kanban.list(wfId, opts?)` | Grouped by phase |
98
+ | Open activity sidebar | `hailer.ui.activity.open(id, opts?)` | |
99
+ | Open create form | `hailer.ui.activity.create(wfId, opts?)` | v2.4.2+: **promise never settles on cancel** — see gotcha below |
100
+ | Upload file | `hailer.file.upload(file, name, opts)` (SDK ≥2.4.x) / `hailer.ui.files.uploadFile(file, name, opts)` (legacy, deprecated in 2.4.x) | Returns fileId |
101
+ | Show toast | `hailer.ui.snackbar.open(text, btn, ms?)` | Button is display-only, no action callback |
102
+ | Get current user | `hailer.user.current()` | |
103
+
104
+ > **Canonical read/write shape:**
105
+ >
106
+ > | Context | Shape |
107
+ > |---------|-------|
108
+ > | READ: REST/MCP `v3.activity.list` | `{ type: "text", value: "foo" }` |
109
+ > | READ: app-sdk v2 | `"foo"` (raw value — no wrapper) |
110
+ > | READ: app-sdk v1 | `{ value: "foo" }` |
111
+ > | WRITE: app-sdk (create/update), MCP | `"foo"` (plain value — never wrap) |
112
+ >
113
+ > **The `{type,value}` object is READ-only.** Passing it to app-sdk write calls sends wrong data silently. Passing it to REST/MCP write calls is rejected with code 191.
114
+ </api-quick-index>
115
+
116
+ <rest-api-from-app>
117
+ ## Calling the REST API from an App (v2 only)
118
+
119
+ `hailer.http.fetch` reaches the full authenticated `api.hailer.com` REST surface as the current user (v2 only; v1 apps lack `http.*`). See `hailer-app-primitives` → `hailerRest` for the canonical typed wrapper.
51
120
 
52
121
  ```typescript
53
- // For app constants, read from local workspace files:
54
- // - workspace/enums.ts (IDs)
55
- // - workspace/[Workflow]_[id]/fields.ts (field definitions)
56
- // - workspace/[Workflow]_[id]/phases.ts (phase definitions)
122
+ const res = await hailer.http.fetch('https://api.hailer.com/api/v2/some.endpoint', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify(payload),
126
+ });
127
+ // body is a RAW STRING — must JSON.parse manually
57
128
  ```
129
+
130
+ - **URL must start with `https://api.hailer.com`** (`monolith.hailer.com` is private-DNS only — fails at DNS)
131
+ - **Web:** allowlist is global — works out-of-the-box
132
+ - **iOS:** `allowedUrls` is per-app, default empty → 400 "URL not allowed"
133
+ - **`http.upload` is web-only** — absent on the iOS bridge
134
+
135
+ When `hailer.request('x')` returns "Cannot find api endpoint", try `hailer.http.fetch` before concluding "can't":
136
+ - **code 191** = handler reached, arg validation failed → endpoint IS reachable; fix the args
137
+ - **code 7** = not exposed on the HTTP route → WS-only or truly blocked
138
+ - **`messenger.*` is legacy no-version namespace** — use `/api/messenger.send` NOT `/api/v3/messenger.send`
139
+ </rest-api-from-app>
140
+
141
+ <critical-rules>
142
+ ## Scaffolding
143
+
144
+ **When creating an app by copying a sibling app**, rewrite `index.html` `<title>` (and manifest `name`) immediately — stale titles cause wrong-app false positives during browser verification.
145
+
146
+ **To create a new app:** `npx @hailer/create-app <name> --template react-ts` then `npx hailer-mcp prep-app <name>`. See `create-and-publish-app` skill for the full non-interactive form and gotchas.
147
+
148
+ Use `mcp__hailer__manage_app` (`action:'create'`) only when you need a Hailer entry pointing to an already-deployed URL (no local files).
149
+
150
+ **For project data (workflows, fields, phases):** Read `workspace/` TypeScript files directly. Do NOT use MCP tools for data structure queries.
151
+
152
+ **Template portability — no insights:** A workflow used as a portable template must not depend on insights. Insights are not key-portable across workspaces. Template apps do client-side aggregation and ship an empty `workspace/insights.ts`.
58
153
  </critical-rules>
59
154
 
60
155
  <sdk-setup>
@@ -74,14 +169,11 @@ import { useHailer } from '@hailer/app-sdk';
74
169
  function App() {
75
170
  const { inside, hailer } = useHailer();
76
171
 
77
- // CORRECT dependency array
78
172
  useEffect(() => {
79
173
  // fetch data
80
174
  }, [inside]); // Use [inside] NOT [hailer]
81
175
 
82
- // Early return AFTER hooks
83
- if (!inside) return <Text>Open this app inside Hailer</Text>;
84
-
176
+ if (!inside) return <Text>Open this app inside Hailer</Text>; // After hooks
85
177
  return <Box>...</Box>;
86
178
  }
87
179
  ```
@@ -90,36 +182,14 @@ function App() {
90
182
  <usehailer-fix>
91
183
  ## CRITICAL: Replace Scaffold's useHailer Hook
92
184
 
93
- **The scaffold generates a buggy useHailer hook.** After scaffolding, ALWAYS replace `src/hailer/use-hailer.ts` with this shared-state implementation:
94
-
95
- ### Why the Scaffold's Hook is Broken
185
+ **The scaffold generates a buggy useHailer hook** each component gets its own `useState`, so child pages see `inside: false` even when `App.tsx` sees `inside: true`.
96
186
 
97
- The scaffold creates a hook using per-component `useState`:
98
-
99
- ```typescript
100
- // ❌ BUGGY - each component gets its own state
101
- function useHailer() {
102
- const [inside, setInside] = useState(false); // Each component gets separate copy!
103
-
104
- useEffect(() => {
105
- hailer.init({ config: () => setInside(true) }); // Only updates THIS component
106
- }, []);
107
-
108
- return { inside, hailer };
109
- }
110
- ```
111
-
112
- **Result:** App.tsx sees `inside: true`, but child pages (Dashboard, Settings) still see `inside: false`.
113
-
114
- ### The Fix: Shared State with useSyncExternalStore
115
-
116
- Replace `src/hailer/use-hailer.ts` with:
187
+ After scaffolding, ALWAYS replace `src/hailer/use-hailer.ts` with this shared-state implementation:
117
188
 
118
189
  ```typescript
119
190
  import { useSyncExternalStore } from 'react';
120
191
  import HailerApi from '@hailer/app-sdk';
121
192
 
122
- // Types
123
193
  interface HailerState {
124
194
  inside: boolean;
125
195
  hailer: ReturnType<typeof HailerApi> | null;
@@ -138,7 +208,6 @@ declare global {
138
208
  }
139
209
  }
140
210
 
141
- // Initialize store once on window
142
211
  function getStore() {
143
212
  if (!window.__hailerStore) {
144
213
  window.__hailerStore = {
@@ -148,26 +217,21 @@ function getStore() {
148
217
  this.listeners.add(listener);
149
218
  return () => this.listeners.delete(listener);
150
219
  },
151
- getSnapshot() {
152
- return this.state;
153
- },
220
+ getSnapshot() { return this.state; },
154
221
  setState(newState) {
155
222
  this.state = { ...this.state, ...newState };
156
223
  this.listeners.forEach((l) => l());
157
224
  },
158
225
  };
159
226
 
160
- // Initialize SDK once
161
227
  const api = HailerApi({
162
- config: (inside, cfg) => { // SDK passes (inside: boolean, config: object)
163
- window.__hailerStore!.setState({
164
- inside: inside,
165
- config: cfg?.fields ?? null,
166
- });
167
- },
168
- error: (err) => {
169
- console.error('Hailer SDK error:', err);
228
+ config: (cfg) => {
229
+ // v1: cfg.fields is a nested object { fieldKey: { value: ... } }
230
+ // v2: cfg IS the flat Record<string, string> directly — no .fields nesting
231
+ const flat = cfg?.fields ?? cfg ?? null;
232
+ window.__hailerStore!.setState({ inside: true, config: flat });
170
233
  },
234
+ error: (err) => console.error('Hailer SDK error:', err),
171
235
  });
172
236
  window.__hailerStore.setState({ hailer: api });
173
237
  }
@@ -180,1069 +244,449 @@ export default function useHailer() {
180
244
  store.subscribe.bind(store),
181
245
  store.getSnapshot.bind(store)
182
246
  );
183
-
184
- return {
185
- inside: state.inside,
186
- hailer: state.hailer!,
187
- config: state.config,
188
- };
247
+ return { inside: state.inside, hailer: state.hailer!, config: state.config };
189
248
  }
190
249
  ```
191
250
 
192
- ### Why This Works
251
+ **Why:** Single store on `window` → all components share one source of truth. `useSyncExternalStore` is React 18's official pattern for external state.
193
252
 
194
- 1. **Single store on `window`** - All components share one source of truth
195
- 2. **SDK initialized once** - No duplicate callbacks
196
- 3. **useSyncExternalStore** - React 18's official pattern for external state
197
- 4. **All components update together** - When `inside` changes, every subscriber re-renders
253
+ ### Scaffold tsc bugs fix up front
198
254
 
199
- ### Giuseppe Rule
255
+ Freshly scaffolded apps carry tsc errors in `src/hailer/use-app.ts` — at minimum the `StoreSet` `replace?: boolean` → `replace?: false` fix (zustand 5). Apply immediately. `npx hailer-mcp prep-app <app-dir>` automates this fix.
200
256
 
201
- After scaffolding with `npx @hailer/create-app`, ALWAYS replace `src/hailer/use-hailer.ts` with the shared-state version above.
257
+ A `vite-env.d.ts` containing an `import` becomes a module — ambient declarations stop being global; wrap them in `declare global { }` to restore globals.
202
258
  </usehailer-fix>
203
259
 
204
- <sdk-api>
205
- ## Activity API
260
+ <onready-overfetch>
261
+ ## `onReady` / `useEffect([inside])` — Only Fetch What You Need
206
262
 
207
- ```typescript
208
- // List activities from workflow phase
209
- const activities = await hailer.activity.list(workflowId, phaseId, options?);
263
+ **Calling `hailer.workflow.list()`, `hailer.user.list()`, or `hailer.permission.map()` in `onReady` causes parent-frame spam and CORS errors when the app does not actually need those calls.**
210
264
 
211
- // Get single activity
212
- const activity = await hailer.activity.get(activityId);
265
+ These calls fire translation-scope loads for each workflow, producing repeated `Error when listening to scope load: undefined` and `Failed to load translations for scope: misc` errors — re-triggered on every incoming signal.
213
266
 
214
- // Create activities
215
- const created = await hailer.activity.create(workflowId, activities[], options?);
216
-
217
- // Update activities (returns count)
218
- const count = await hailer.activity.update(activities[], options);
219
-
220
- // Remove activities (returns count)
221
- const count = await hailer.activity.remove(activityIds[]);
222
- ```
223
-
224
- ### ActivityListOptions
267
+ A standalone or chat app that does not use workflow data should call only what it needs:
225
268
 
226
269
  ```typescript
227
- interface ActivityListOptions {
228
- sortBy?: 'name' | 'created' | 'updated' | 'following' | 'owner' | 'team' | 'completedOn' | 'priority';
229
- sortOrder?: 'asc' | 'desc';
230
- limit?: number;
231
- skip?: number;
232
- includeUsers?: boolean;
233
- includeTeams?: boolean;
234
- includeHistory?: boolean;
235
- filters?: any;
236
- }
270
+ // Correct for a standalone/chat app
271
+ useEffect(() => {
272
+ if (!inside) return;
273
+ hailer.user.current().then(u => setCurrentUser(u));
274
+ }, [inside]);
237
275
  ```
276
+ </onready-overfetch>
238
277
 
239
- ### ActivityCreateOptions
240
-
241
- ```typescript
242
- interface ActivityCreateOptions {
243
- teamId?: string; // Assign to team
244
- fileIds?: string[]; // Attach files (use ui.files.uploadFile first)
245
- followerIds?: string[]; // Add followers
246
- location?: {
247
- label?: string;
248
- type: 'area' | 'point' | 'polyline';
249
- data: [{ lat: number; lng: number }];
250
- };
251
- discussionId?: string; // Link to discussion
252
- phaseId?: string; // Initial phase (defaults to first)
253
- returnDocument?: boolean; // Return full activity after create
254
- ignoreRequired?: boolean; // Skip required field validation
255
- }
256
- ```
278
+ <sdk-api>
279
+ ## Field Keys — Default Build Pattern
257
280
 
258
- ### Activity Interface
281
+ **All new app builds reference workflows, phases, and fields by stable key → runtime hex via a `buildXxxSchema(workflows)` resolver.** Never hardcode hex IDs in app source. Activity IDs are the only exception.
259
282
 
260
283
  ```typescript
261
- interface Activity {
262
- _id: string;
263
- name: string;
264
- process: string;
265
- currentPhase: string;
266
- fields?: Record<string, { value: unknown }>;
267
- files?: string[];
268
- followers?: string[];
269
- created?: number;
270
- updated?: number;
271
- updatedBy?: string;
272
- priority?: number;
273
- location?: {
274
- type: 'point' | 'area' | 'polyline';
275
- label: string | null;
276
- data: Array<{ lat: number; lng: number }>;
277
- };
278
- }
284
+ // src/constants/sales.ts — stable slugs, portable across tenants
285
+ export const WORKFLOW_KEYS = { DEALS: 'sales-deals' } as const;
286
+ export const PHASE_KEYS = { OPEN: 'open', WON: 'won', LOST: 'lost' } as const;
287
+ export const FIELD_KEYS = { VALUE: 'dealValue', OWNER: 'owner' } as const;
279
288
  ```
280
289
 
281
- ### Phase Transitions (Moving Activities Between Phases)
282
-
283
- **CRITICAL:** The SDK does NOT have a `hailer.activity.move()` method. To move activities between phases, use `hailer.activity.update()` with the `phaseId` parameter.
284
-
285
- ```typescript
286
- // CORRECT - Use activity.update() with phaseId
287
- await hailer.activity.update([
288
- {
289
- _id: activityId,
290
- phaseId: newPhaseId, // Move to different phase
291
- },
292
- ], {});
293
-
294
- // ❌ WRONG - activity.move() DOES NOT EXIST
295
- // await hailer.activity.move(activityId, newPhaseId); // This method doesn't exist in the SDK!
296
- ```
290
+ **Missing-precondition rule:** Before building, verify: `grep -c '^\s*key:' workspace/*/fields.ts`. A workspace with zero keys means `auto_set_keys` never ran. Do not build a static hex table dressed as a resolver — escalate: "Run `mcp__hailer__auto_set_keys({ workflowId })` for each workflow first."
297
291
 
298
- **Example: Move multiple activities to new phase**
299
- ```typescript
300
- const activityIds = ['id1', 'id2', 'id3'];
301
- const targetPhaseId = 'phaseId789';
302
-
303
- await hailer.activity.update(
304
- activityIds.map(id => ({
305
- _id: id,
306
- phaseId: targetPhaseId,
307
- })),
308
- {}
309
- );
310
- ```
292
+ **Passing a workflow key directly to `hailer.activity.list()` returns code 127** ("Invalid process key or missing workspace id") — always pass resolved hex.
311
293
 
312
- ### CRITICAL: activity.list() Requires Valid phaseId
294
+ > **Resolver primitive-capture gotcha.** `FIELD_IDS` objects start as empty strings and are filled in at app boot. Any primitive string value copied at module-evaluation time stays `''` forever. Use object references (`FIELD_IDS.mu`) inside JSX/hooks, never copy a primitive leaf at module level.
313
295
 
314
- **Problem:** `hailer.activity.list(workflowId, '', options)` fails - empty phaseId not allowed.
296
+ ## Activity API
315
297
 
316
- **Solution:** Query all phases in parallel:
317
298
  ```typescript
318
- const ALL_PHASES = ['phaseId1', 'phaseId2', 'phaseId3'];
319
-
320
- const phaseResults = await Promise.all(
321
- ALL_PHASES.map((phaseId) =>
322
- hailer.activity.list(workflowId, phaseId, { limit: 500 }).catch(() => [])
323
- )
324
- );
325
- const allActivities = phaseResults.flat();
299
+ const activities = await hailer.activity.list(workflowId, phaseId);
300
+ const activity = await hailer.activity.get(activityId); // single arg, both v1 and v2
301
+ const created = await hailer.activity.create(workflowId, activities[], options?);
302
+ const count = await hailer.activity.update(activities[], options);
303
+ const count = await hailer.activity.remove(activityIds[]);
326
304
  ```
327
305
 
328
- ### Phase Selection
329
-
330
- Don't blindly fetch all phases — fetch only what the user needs to see (e.g., active only for dashboards, all except archived for kanban). For field value formats and phase selection patterns, see the **sdk-activity-patterns** skill.
306
+ ### activity.list() — Pagination
331
307
 
332
- ## Kanban API
308
+ **Production limit cap (SUSPECT #1 FOR EMPTY DATA ON LOAD):** `hailer.activity.list()` silently returns `[]` on production when `limit` is too large — **no error thrown**, even with full admin permissions. `limit: 100` works, `limit: 1000` returns empty. Use `pageSize ≤ 200`.
333
309
 
334
310
  ```typescript
335
- // List activities grouped by phase (kanban view)
336
- const kanban = await hailer.activity.kanban.list(workflowId, options?);
337
- // Returns: { map: { [phaseId]: { activities: [...], meta: { count, ... } } } }
338
-
339
- // Load single activity in kanban format
340
- const item = await hailer.activity.kanban.load(activityId);
341
- // Returns: { activity: {...}, process: string, phase: string }
342
-
343
- // Update activity priority
344
- await hailer.activity.kanban.updatePriority(activityId, priority);
345
- ```
346
-
347
- ### KanbanListOptions
348
-
349
- ```typescript
350
- interface ActivityKanbanListOptions {
351
- filter: {
352
- user?: { uid: string; field: string };
353
- account?: string;
354
- team?: string;
355
- dates?: { field: string; start: number; end: number };
356
- };
357
- search?: string;
358
- limit?: number;
359
- skip?: number;
360
- phase?: string;
361
- includeUsers?: boolean;
362
- includeTeams?: boolean;
311
+ async function listAll(hailer: any, workflowId: string, phaseId: string, opts = {}, pageSize = 200) {
312
+ const all: any[] = [];
313
+ let skip = 0;
314
+ while (true) {
315
+ const page = await hailer.activity.list(workflowId, phaseId, { ...opts, limit: pageSize, skip });
316
+ all.push(...page);
317
+ if (page.length < pageSize) break;
318
+ skip += pageSize;
319
+ if (skip > 50000) break; // safety cap
320
+ }
321
+ return all;
363
322
  }
364
323
  ```
365
324
 
366
- ## UI API
367
-
368
- ### Activity UI
369
-
370
- ```typescript
371
- // Open activity in sidebar
372
- await hailer.ui.activity.open(activityId, options?);
373
-
374
- type ActivityTabTypes = 'detail' | 'discussion' | 'files' | 'location' | 'linkedFrom' | 'options';
375
-
376
- // Open with specific tab
377
- await hailer.ui.activity.open(activityId, { tab: 'files' });
378
-
379
- // Open create form (returns created activity or null if cancelled)
380
- const result = await hailer.ui.activity.create(workflowId, {
381
- name?: string,
382
- fields?: { [fieldId]: value },
383
- location?: { type: 'point', data: [{ lat, lng }] }
384
- });
385
-
386
- // Bulk edit multiple activities
387
- await hailer.ui.activity.editMultiple(activityIds[]);
388
- ```
389
-
390
- **Note:** `hailer.openSidebar()` does NOT exist - use `hailer.ui.activity.open()`.
325
+ ### activity.list() Gotchas
391
326
 
392
- ### File Upload
327
+ - **Valid phaseId required:** Empty string `''` returns nothing.
328
+ - **Don't assume phase names** — check `phases.ts` for exact strings (e.g. `"Uudet"` not `"New"`).
329
+ - **Large `limit` silently returns `[]` on prod** — Keep `pageSize ≤ 200`.
330
+ - **`returnFlat: true` is REST/MCP-only** — do NOT pass it to app-sdk. In v2 app-sdk, field values come back as raw values already; passing `returnFlat` silently has no effect.
331
+ - **`activity.currentPhase` is unreliable from list/kanban paths.** Fix: stamp the queried phaseId: `all.push({ ...a, currentPhase: a.currentPhase || phaseId })`. When present, the value may be hex OR key — include entries for both in lookup maps.
393
332
 
333
+ Multi-phase fetch:
394
334
  ```typescript
395
- // Upload file to Hailer (returns file ID)
396
- const fileId = await hailer.ui.files.uploadFile(
397
- file, // File object from <input type="file">
398
- filename, // Desired filename
399
- { isPublic?: boolean }
335
+ const phaseResults = await Promise.all(
336
+ [PHASES.ACTIVE, PHASES.COMPLETED].map(phaseId =>
337
+ hailer.activity.list(workflowId, phaseId).catch(() => [])
338
+ )
400
339
  );
401
-
402
- // Then attach to activity on create:
403
- await hailer.activity.create(workflowId, [{ name: 'Doc' }], { fileIds: [fileId] });
340
+ const all = phaseResults.flat();
404
341
  ```
405
342
 
406
- ### Snackbar (Toast Notifications)
407
-
408
- ```typescript
409
- // Show notification
410
- await hailer.ui.snackbar.open(text, buttonLabel, duration?);
411
-
412
- // Examples
413
- hailer.ui.snackbar.open('Saved!', 'OK');
414
- hailer.ui.snackbar.open('Deleted', 'Undo', 5000);
415
- ```
343
+ **Phase fan-out across `phasesOrder` for archived rows.** When listing all rows including archived phases, iterate `workflowConfig.phasesOrder` rather than assuming one phase covers everything.
416
344
 
417
- ### Insight UI
345
+ ### Phase Transitions
418
346
 
347
+ No `hailer.activity.move()` — use update with phaseId:
419
348
  ```typescript
420
- await hailer.ui.insight.create(workspaceId?); // Open create dialog
421
- await hailer.ui.insight.edit(insightId); // Open edit dialog
422
- await hailer.ui.insight.delete(insightId); // Open delete confirmation
423
- await hailer.ui.insight.permission(insightId); // Open permissions dialog
349
+ await hailer.activity.update([{ _id: activityId, phaseId: newPhaseId }], {});
424
350
  ```
425
351
 
426
- ## Insight API
352
+ ### hailer.activity.create() — phaseId is Optional
427
353
 
428
- ```typescript
429
- // Get insight data (SQL query results)
430
- const data = await hailer.insight.data(insightId, { update?: true });
354
+ `phaseId` in the options argument is optional. All these forms work:
355
+ - `hailer.activity.create(wfId, [{name, fields, teamId}])` options omitted
356
+ - `hailer.activity.create(wfId, [{name, fields, teamId}], {})` — empty options
357
+ - `hailer.activity.create(wfId, [{name, fields, teamId}], { phaseId: 'X' })` — phase-targeted
431
358
 
432
- // List all insights
433
- const insights = await hailer.insight.list();
359
+ ### hailer.ui.activity.create() vs hailer.activity.create()
434
360
 
435
- // Update insight
436
- const updated = await hailer.insight.update(insightId, partialUpdate);
437
- ```
361
+ `hailer.ui.activity.create(processId)` opens the native Hailer "new activity" sidebar.
438
362
 
439
- **Response structure:**
440
- ```typescript
441
- interface InsightData {
442
- columns: string[];
443
- rows: any[][];
444
- }
363
+ **Critical gotcha — cancel hangs the promise forever (v2.x ≥2.4.2).** If the user dismisses the form without saving, the awaited promise NEVER settles. Do NOT `await` it directly. Treat as fire-and-forget, or react to activity-created signals.
445
364
 
446
- interface InsightDoc {
447
- _id: string;
448
- name: string;
449
- // ... other insight properties
450
- }
451
- ```
365
+ ### Activity Object Field Names — v1 vs v2
452
366
 
453
- ## Workflow API
367
+ | Property | v1.x | v2.x (2.4.2 runtime) |
368
+ |----------|------|------|
369
+ | Field value read | `activity.fields?.[id]?.value` | `activity.fields?.[id]` (raw — `.value` wrapper gone) |
370
+ | Team ID | (not on object) | `activity.teamId` (flat string) |
454
371
 
455
- ```typescript
456
- // List all workflows
457
- const workflows = await hailer.workflow.list();
372
+ Runtime-confirmed shape for `activity.get()` on @hailer/app-sdk **2.4.2**: `{_id, name, created, updated, sequence, priority, files, fields, followerIds, workflowId, phaseId, teamId}`.
458
373
 
459
- // Get single workflow with full schema
460
- const workflow = await hailer.workflow.get(workflowId);
461
- ```
374
+ **Silent failure pattern v1→v2 migration:** Code that reads `activity.fields?.[id]?.value` compiles without errors in v2 but silently returns `undefined` everywhere.
462
375
 
463
- ## User API
376
+ ### Editable Function Fields
464
377
 
465
- ```typescript
466
- // Get current logged-in user
467
- const me = await hailer.user.current();
468
- // Returns: { _id, email, firstname, lastname, ... }
378
+ A function field with `editable: true` is a valid write target via `hailer.activity.update()` — pass a plain scalar value. A stored value wins over the computed default.
469
379
 
470
- // Get specific user by ID
471
- const user = await hailer.user.get(userId);
380
+ ### hailer.insight.data() Option Types
472
381
 
473
- // List all workspace users (returns object keyed by user ID)
474
- const users = await hailer.user.list();
475
- ```
476
-
477
- ### Getting User Info for Personalized Greeting
478
-
479
- **The config callback does NOT include user info:**
480
- ```typescript
481
- // ❌ WRONG - config only has { fields: {} }
482
- const { config } = useHailer();
483
- const userName = config?.userName; // undefined!
484
- ```
485
-
486
- **Use hailer.user.current() instead:**
487
- ```typescript
488
- const { inside, hailer } = useHailer();
489
- const [userName, setUserName] = useState<string>('');
490
-
491
- useEffect(() => {
492
- if (!inside) return;
493
-
494
- hailer.user.current().then(user => {
495
- setUserName(user.firstname || 'there');
496
- });
497
- }, [inside]);
498
-
499
- return <Heading>Hello, {userName}!</Heading>;
500
- ```
501
-
502
- ## Workspace API
503
-
504
- ```typescript
505
- // Get current workspace
506
- const workspace = await hailer.workspace.current();
507
-
508
- // List workspaces (personal apps only)
509
- const workspaces = await hailer.workspace.list();
510
- ```
382
+ Pass `{ update: true }` to force a fresh fetch. Passing `{}` (no `update` key) has been observed to hang silently in production.
511
383
  </sdk-api>
512
384
 
513
- <field-resolver>
514
- ## Field Resolver Pattern
515
-
516
- Every scaffolded app includes `src/hailer/field-resolver.ts`. It resolves human-readable field keys to hex IDs at runtime.
517
-
518
- ### Functions
519
-
520
- **`createFieldResolver(workflowFields)`** — low-level. Takes a workflow's `fields` map, returns a resolver function.
521
-
522
- **`useFieldResolver(workflows, workflowId)`** — hook-friendly wrapper. Takes `useApp().app.workflows` and a workflow ID.
523
-
524
- ### How It Works
525
-
526
- - Pass a field **key** (e.g., `'matchDate'`) → resolves to hex ID at runtime
527
- - Pass a field **hex ID** (e.g., `'691ffdf84217e9e8434e5697'`) → returns as-is
528
- - Workflows without keys → passthrough, hex IDs still work
529
-
530
- ### Usage
531
-
532
- ```typescript
533
- import { useFieldResolver } from './hailer/field-resolver';
534
- import { useApp } from './hailer/use-app';
535
-
536
- const WORKFLOW_ID = '691ffdf84217e9e8434e56a5';
537
- const FIELD_KEYS = {
538
- MATCH_DATE: 'matchDate',
539
- HOME_TEAM: 'homeTeam',
540
- };
541
-
542
- function MyComponent() {
543
- const { app } = useApp();
544
- const f = useFieldResolver(app.workflows, WORKFLOW_ID);
545
-
546
- // f() resolves keys to IDs — works with either
547
- const date = activity.fields?.[f(FIELD_KEYS.MATCH_DATE)];
548
- const team = activity.fields?.[f(FIELD_KEYS.HOME_TEAM)];
549
- }
550
- ```
551
-
552
- ### Key Points
553
-
554
- - Define field keys as constants (human-readable, portable across workspaces)
555
- - The resolver handles both keys and hex IDs — safe to pass either
556
- - Wait for workflow data before fetching activities — add the workflow to `useEffect` deps
557
- - `useApp().app.workflows` is loaded on init by the scaffold's `onReady` handler
558
- </field-resolver>
559
-
560
385
  <field-patterns>
561
386
  ## Extracting Field Values
562
387
 
563
388
  ```typescript
564
- // Fields are optional and nested
565
- interface Activity {
566
- fields?: Record<string, { value: unknown }>;
567
- }
389
+ // v1 .value wrapper
390
+ const amount = activity.fields?.[FIELD_ID]?.value as number;
391
+
392
+ // v2 — raw value directly
393
+ const amount = activity.fields?.[FIELD_ID] as number;
568
394
 
569
- // Safe extraction helper
395
+ // Safe extraction helper (v1 shape)
570
396
  function getFieldValue<T>(activity: Activity, fieldId: string, defaultValue: T): T {
571
397
  return (activity.fields?.[fieldId]?.value as T) ?? defaultValue;
572
398
  }
573
399
 
574
- // Usage
575
- const name = getFieldValue(activity, 'fieldId123', '');
576
- const count = getFieldValue(activity, 'fieldId456', 0);
577
- const date = getFieldValue(activity, 'fieldId789', null);
578
- ```
579
-
580
- ## Field Types
581
-
582
- ```typescript
583
- // Text field
584
- const text = activity.fields?.['fieldId']?.value as string;
585
-
586
- // Number field
587
- const num = activity.fields?.['fieldId']?.value as number;
588
-
589
- // Date field (timestamp)
590
- const date = activity.fields?.['fieldId']?.value as number;
591
- const formatted = new Date(date).toLocaleDateString();
400
+ // ActivityLink — READ returns {_id, name} object, WRITE takes plain ID string
401
+ interface ActivityLinkValue { _id: string; name: string; }
402
+ const linked = activity.fields?.['fieldId']?.value as ActivityLinkValue;
592
403
 
593
- // Enum/Select field
594
- const status = activity.fields?.['fieldId']?.value as string;
404
+ // Writing an ActivityLink field — plain ID only
405
+ await hailer.activity.update([{
406
+ _id: activityId,
407
+ fields: { [LINK_FIELD_ID]: someActivityId }, // string, not {_id, name}
408
+ }], {});
595
409
 
596
- // ActivityLink field (reference to another activity)
597
- // IMPORTANT: ActivityLink has nested structure, not direct values
598
- interface ActivityLinkValue {
599
- _id: string;
600
- name: string;
410
+ // Defensive readLink helpers activity.list returns activitylink as {_id,name} OR [{_id,name}]
411
+ function readLinkId(v: unknown): string | undefined {
412
+ if (Array.isArray(v)) return (v[0] as any)?._id;
413
+ if (v && typeof v === 'object') return (v as any)._id;
414
+ if (typeof v === 'string') return v;
415
+ return undefined;
601
416
  }
602
- const linked = activity.fields?.['fieldId']?.value as ActivityLinkValue;
603
- const linkedId = linked?._id; // Get linked activity ID
604
- const linkedName = linked?.name || 'Unknown'; // Get display name
417
+ function readLinkName(v: unknown): string | undefined {
418
+ if (Array.isArray(v)) return (v[0] as any)?.name;
419
+ if (v && typeof v === 'object') return (v as any).name;
420
+ return undefined;
421
+ }
422
+ // Match linked activities by id, not name — names break silently on rename
605
423
 
606
424
  // User field
607
- interface UserValue {
608
- _id: string;
609
- firstname: string;
610
- lastname: string;
611
- }
425
+ interface UserValue { _id: string; firstname: string; lastname: string; }
612
426
  const user = activity.fields?.['fieldId']?.value as UserValue;
613
427
  const userName = user ? `${user.firstname} ${user.lastname}` : 'Unknown';
428
+
429
+ // Date (timestamp)
430
+ const formatted = new Date(activity.fields?.['fieldId']?.value as number).toLocaleDateString();
614
431
  ```
615
432
 
616
- ## Finnish Date Parsing
433
+ ### React error #31 — range field rendered raw in JSX
617
434
 
618
- Insights and some fields return Finnish date strings (`dd.mm.yyyy`) instead of timestamps. These can't be sorted directly.
435
+ **React error #31 = a `timerange` / `daterange` / `datetimerange` field value rendered directly in JSX.**
619
436
 
620
437
  ```typescript
621
- // WRONG - Number("03.02.2026") returns NaN
622
- dates.sort((a, b) => Number(a) - Number(b));
623
-
624
- // CORRECT - Parse Finnish dates to Date objects
625
- function parseFinnishDate(dateStr: string): Date | null {
626
- // Matches "03.02.2026" or "03.02.2026 10:00"
627
- const match = dateStr.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
628
- if (!match) return null;
629
-
630
- const [, day, month, year, hours = '0', minutes = '0'] = match;
631
- return new Date(
632
- parseInt(year),
633
- parseInt(month) - 1, // JS months are 0-indexed
634
- parseInt(day),
635
- parseInt(hours),
636
- parseInt(minutes)
637
- );
638
- }
639
-
640
- // Sorting Finnish dates
641
- items.sort((a, b) => {
642
- const dateA = parseFinnishDate(a.dateField);
643
- const dateB = parseFinnishDate(b.dateField);
644
- if (!dateA || !dateB) return 0;
645
- return dateA.getTime() - dateB.getTime();
646
- });
647
-
648
- // Formatting back to Finnish
649
- function formatFinnishDate(date: Date): string {
650
- return date.toLocaleDateString('fi-FI'); // "3.2.2026"
651
- }
652
-
653
- function formatFinnishDateTime(date: Date): string {
654
- return date.toLocaleString('fi-FI', {
655
- day: 'numeric',
656
- month: 'numeric',
657
- year: 'numeric',
658
- hour: '2-digit',
659
- minute: '2-digit',
660
- }); // "3.2.2026 klo 10.00"
438
+ function formatTimeRange(v: unknown): string {
439
+ if (!v) return '';
440
+ if (typeof v === 'object' && v !== null && 'start' in v && 'end' in v) {
441
+ const r = v as { start: number; end: number };
442
+ return `${new Date(r.start).toLocaleTimeString('fi-FI', { timeZone: 'Europe/Helsinki', hour: '2-digit', minute: '2-digit' })} – ${new Date(r.end).toLocaleTimeString('fi-FI', { timeZone: 'Europe/Helsinki', hour: '2-digit', minute: '2-digit' })}`;
443
+ }
444
+ return String(v);
661
445
  }
662
446
  ```
663
447
 
664
- **When to use:**
665
- - Insight data returns formatted dates (not timestamps)
666
- - Text fields containing dates
667
- - Displaying dates to Finnish users
668
- </field-patterns>
448
+ ## Write Field Formats (App-SDK)
669
449
 
670
- <component-templates>
671
- ## Activity Table
450
+ Pass plain values to `hailer.activity.create` / `hailer.activity.update` — no `{type, value}` or `{value}` wrappers.
672
451
 
673
452
  ```typescript
674
- import { Table, Thead, Tbody, Tr, Th, Td, Box, Spinner, Text } from '@chakra-ui/react';
675
-
676
- interface Activity {
677
- _id: string;
678
- name: string;
679
- fields?: Record<string, { value: unknown }>;
680
- }
681
-
682
- interface Props {
683
- activities: Activity[];
684
- loading: boolean;
685
- columns: { fieldId: string; label: string }[];
686
- }
687
-
688
- function ActivityTable({ activities, loading, columns }: Props) {
689
- if (loading) return <Spinner />;
690
- if (activities.length === 0) return <Text>No data</Text>;
453
+ // activity.create() format
454
+ await hailer.activity.create(WORKFLOW_ID, [
455
+ {
456
+ name: 'Activity name',
457
+ fields: {
458
+ [FIELD_ID]: 'string value',
459
+ [NUMBER_FIELD]: 42,
460
+ [ACTIVITYLINK_FIELD]: 'linkedActId', // plain string ID — not {_id, name}
461
+ },
462
+ },
463
+ ]);
691
464
 
692
- return (
693
- <Table variant="simple" size="sm">
694
- <Thead>
695
- <Tr>
696
- <Th>Name</Th>
697
- {columns.map(col => (
698
- <Th key={col.fieldId}>{col.label}</Th>
699
- ))}
700
- </Tr>
701
- </Thead>
702
- <Tbody>
703
- {activities.map(activity => (
704
- <Tr key={activity._id}>
705
- <Td>{activity.name}</Td>
706
- {columns.map(col => (
707
- <Td key={col.fieldId}>
708
- {String(activity.fields?.[col.fieldId]?.value ?? '-')}
709
- </Td>
710
- ))}
711
- </Tr>
712
- ))}
713
- </Tbody>
714
- </Table>
715
- );
716
- }
465
+ // activity.update() format (null clears a field)
466
+ await hailer.activity.update([
467
+ {
468
+ _id: 'activityId',
469
+ fields: {
470
+ [FIELD_ID]: 'new value',
471
+ [FIELD_TO_CLEAR]: null,
472
+ },
473
+ phaseId: 'newPhaseId', // optional - move phase
474
+ },
475
+ ], {});
717
476
  ```
718
477
 
719
- ## Activity Card
720
-
721
- ```typescript
722
- import { Box, Heading, Text, VStack, useColorModeValue } from '@chakra-ui/react';
723
-
724
- interface Props {
725
- activity: Activity;
726
- fieldId: string;
727
- fieldLabel: string;
728
- }
729
-
730
- function ActivityCard({ activity, fieldId, fieldLabel }: Props) {
731
- const bg = useColorModeValue('white', 'gray.700');
732
- const borderColor = useColorModeValue('gray.200', 'gray.600');
733
-
734
- return (
735
- <Box
736
- p={4}
737
- bg={bg}
738
- borderRadius="md"
739
- border="1px"
740
- borderColor={borderColor}
741
- >
742
- <VStack align="start" spacing={2}>
743
- <Heading size="sm">{activity.name}</Heading>
744
- <Text fontSize="sm" color="gray.500">
745
- {fieldLabel}: {String(activity.fields?.[fieldId]?.value ?? '-')}
746
- </Text>
747
- </VStack>
748
- </Box>
749
- );
750
- }
751
- ```
478
+ **App-specific create() pitfalls:**
479
+ - Passing single object instead of array — must always be an array
480
+ - Passing `undefined` as options → `ValidationError "debug is not allowed"` — omit or pass `{}`
481
+ - "Missing teams" error (code 127) → workflow needs `enablePreselectedTeam: true` + `preselectedTeam: { account: workspaceOwnerAccountId, team: teamId }`
482
+ </field-patterns>
752
483
 
753
- ## Stats Card
484
+ <app-template>
485
+ ## Full App Skeleton
754
486
 
755
487
  ```typescript
756
- import { Stat, StatLabel, StatNumber, StatHelpText, Box, useColorModeValue } from '@chakra-ui/react';
488
+ const WORKFLOW_ID = 'from_workflows.ts';
489
+ const PHASE_ID = 'from_phases.ts';
490
+ const FIELDS = { STATUS: 'fieldId' } as const;
757
491
 
758
- interface Props {
759
- label: string;
760
- value: number | string;
761
- helpText?: string;
762
- }
492
+ function App() {
493
+ const { inside, hailer } = useHailer();
494
+ const [activities, setActivities] = useState<Activity[]>([]);
495
+ const [loading, setLoading] = useState(true);
763
496
 
764
- function StatsCard({ label, value, helpText }: Props) {
765
- const bg = useColorModeValue('white', 'gray.700');
497
+ useEffect(() => {
498
+ if (!inside) return;
499
+ hailer.activity.list(WORKFLOW_ID, PHASE_ID).then(setActivities).finally(() => setLoading(false));
500
+ }, [inside]); // [inside] NOT [hailer]
766
501
 
502
+ if (!inside) return <Text>Open inside Hailer</Text>;
503
+ if (loading) return <Spinner />;
767
504
  return (
768
- <Box p={4} bg={bg} borderRadius="md" shadow="sm">
769
- <Stat>
770
- <StatLabel>{label}</StatLabel>
771
- <StatNumber>{value}</StatNumber>
772
- {helpText && <StatHelpText>{helpText}</StatHelpText>}
773
- </Stat>
505
+ <Box p={4} bg={useColorModeValue('gray.50', 'gray.800')} minH="100vh">
506
+ <Table variant="simple" size="sm">...</Table>
774
507
  </Box>
775
508
  );
776
509
  }
777
510
  ```
778
- </component-templates>
779
511
 
780
- <theme-patterns>
781
- ## Hailer Theme Colors
512
+ ## App Version Display (REQUIRED)
782
513
 
783
- ```typescript
784
- // Dark mode support - ALWAYS use useColorModeValue
785
- const bg = useColorModeValue('white', 'gray.700');
786
- const borderColor = useColorModeValue('gray.200', 'gray.600');
787
- const textColor = useColorModeValue('gray.800', 'white');
788
- const mutedColor = useColorModeValue('gray.500', 'gray.400');
789
-
790
- // Valid color tokens (DO NOT invent tokens)
791
- // gray.50, gray.100, ..., gray.900
792
- // white, black
793
- // red.500, green.500, blue.500, yellow.500, purple.500
794
- ```
795
-
796
- ## Light/Dark Mode Safety Rules
514
+ Every Hailer app must show its version number visually.
797
515
 
798
- **DON'T use these patterns:**
799
516
  ```typescript
800
- // WRONG - brand colors may not be defined in theme
801
- color="brand.600"
517
+ // vite.config.ts inject version at build time
518
+ import { readFileSync } from 'node:fs';
519
+ const manifest = JSON.parse(readFileSync('./public/manifest.json', 'utf-8'));
802
520
 
803
- // WRONG - hard-coded white fails on light backgrounds
804
- <Text color="white">Always white</Text>
521
+ export default defineConfig({
522
+ define: { __APP_VERSION__: JSON.stringify(manifest.version) },
523
+ });
805
524
 
806
- // WRONG - assumes light mode background
807
- <Badge bg="blue.100" color="blue.800">Status</Badge>
525
+ // In App.tsx
526
+ <Text fontSize="xs" color="gray.400" position="fixed" bottom={1} right={2}>
527
+ v{APP_VERSION}
528
+ </Text>
808
529
  ```
530
+ </app-template>
809
531
 
810
- **DO use these patterns:**
811
- ```typescript
812
- // ✅ CORRECT - explicit colors that exist in Chakra
813
- color="blue.600"
814
-
815
- // ✅ CORRECT - adapts to color mode
816
- <Text color={useColorModeValue('gray.800', 'white')}>Adapts</Text>
532
+ <reactive-data>
533
+ ## Reactive Data
817
534
 
818
- // CORRECT - badge adapts to mode
819
- const badgeBg = useColorModeValue('blue.100', 'blue.700');
820
- const badgeColor = useColorModeValue('blue.800', 'blue.100');
821
- <Badge bg={badgeBg} color={badgeColor}>Status</Badge>
822
- ```
535
+ After any create/update/delete/phase change, re-fetch data shown in the UI:
823
536
 
824
- **Semantic tokens (define once, use everywhere):**
825
537
  ```typescript
826
- // In theme.ts extendTheme
827
- semanticTokens: {
828
- colors: {
829
- appBg: { _light: 'gray.50', _dark: 'gray.800' },
830
- cardBg: { _light: 'white', _dark: 'gray.700' },
831
- textPrimary: { _light: 'gray.800', _dark: 'white' },
832
- textMuted: { _light: 'gray.500', _dark: 'gray.400' },
833
- }
834
- }
538
+ // Pattern 1: Refetch after mutation
539
+ await hailer.activity.create(WORKFLOW_ID, [{ name, fields }], {});
540
+ await loadActivities();
835
541
 
836
- // Usage - no useColorModeValue needed
837
- <Box bg="appBg"><Text color="textPrimary">Clean!</Text></Box>
542
+ // Pattern 2: Refresh key (cross-component)
543
+ const [refreshKey, setRefreshKey] = useState(0);
544
+ <CreateForm onSuccess={() => setRefreshKey(k => k + 1)} />
838
545
  ```
839
546
 
840
- ## Phase-Colored Badges and Bars
547
+ ### Tabs Lazy Mount
841
548
 
842
- When displaying phase/status colors that need to work in both modes:
549
+ **Chakra `<Tabs>` mounts all panels eagerly by default.** Without `isLazy`, every `<TabPanel>` fires effects in parallel even if the user never clicks them.
843
550
 
844
- ```typescript
845
- import { useColorMode } from '@chakra-ui/react';
846
-
847
- // Helper for phase-colored elements
848
- function usePhaseColors(baseColor: string) {
849
- const { colorMode } = useColorMode();
551
+ ```tsx
552
+ <Tabs isLazy>
553
+ <TabPanels>
554
+ <TabPanel><HeavyDataTab /></TabPanel>
555
+ <TabPanel><AnotherTab /></TabPanel>
556
+ </TabPanels>
557
+ </Tabs>
558
+ ```
559
+ </reactive-data>
850
560
 
851
- if (colorMode === 'light') {
852
- return {
853
- bg: `${baseColor}.100`,
854
- color: `${baseColor}.800`,
855
- borderColor: `${baseColor}.200`,
856
- };
857
- } else {
858
- return {
859
- bg: `${baseColor}.700`,
860
- color: `${baseColor}.100`,
861
- borderColor: `${baseColor}.600`,
862
- };
863
- }
864
- }
561
+ <real-time>
562
+ ## Real-Time Updates via Signals
865
563
 
866
- // Usage
867
- function PhaseBadge({ phase, color }: { phase: string; color: string }) {
868
- const colors = usePhaseColors(color);
564
+ v1: `HailerSignal { sig, meta }` + `subscribedSignals` option. v2: `Signal { name, data }`, `subscribedSignals` removed — v2 streams all signals.
869
565
 
870
- return (
871
- <Badge bg={colors.bg} color={colors.color}>
872
- {phase}
873
- </Badge>
874
- );
875
- }
566
+ **v2 2.4.2:** dotted names (`activity.update`, `.create`, `.delete`). Bare `'activity'` fires 0×. Use prefix-match. Payload: `{ activityIds[], workflowId, phaseId }`.
876
567
 
877
- // Event bar with phase color
878
- function EventBar({ title, phaseColor }: { title: string; phaseColor: string }) {
879
- const colors = usePhaseColors(phaseColor);
568
+ **When to use:** Dashboards signals. Forms → refetch after mutation.
880
569
 
881
- return (
882
- <Box
883
- px={2}
884
- py={1}
885
- bg={colors.bg}
886
- color={colors.color}
887
- borderLeft="3px solid"
888
- borderLeftColor={colors.borderColor}
889
- borderRadius="sm"
890
- >
891
- {title}
892
- </Box>
893
- );
894
- }
895
- ```
570
+ ### Debounced Reload (v2)
896
571
 
897
- **Color mapping from Hailer phases:**
898
572
  ```typescript
899
- // Map Hailer phase colors to Chakra color names
900
- const PHASE_COLOR_MAP: Record<string, string> = {
901
- 'blue': 'blue',
902
- 'green': 'green',
903
- 'red': 'red',
904
- 'yellow': 'yellow',
905
- 'purple': 'purple',
906
- 'orange': 'orange',
907
- 'gray': 'gray',
908
- };
573
+ const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
574
+ const inFlightRef = useRef(false);
575
+ signals: (signal: any) => {
576
+ if (!signal.name.startsWith('activity')) return;
577
+ const workflowId = signal.data?.process ?? signal.data?.processId ?? signal.data?.workflowId;
578
+ if (workflowId !== MY_WORKFLOW_ID) return;
579
+ if (inFlightRef.current) return;
580
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
581
+ refreshTimerRef.current = setTimeout(async () => {
582
+ inFlightRef.current = true;
583
+ try { await reload(); } finally { inFlightRef.current = false; }
584
+ }, 800);
585
+ }
909
586
  ```
910
587
 
911
- </theme-patterns>
912
-
913
- <troubleshooting>
914
- ## Workflow Permission Errors
915
-
916
- When SDK calls fail with permission/not-allowed errors, check **workflow configuration in Hailer** first.
917
-
918
- **Common symptoms:**
919
- - Phase transitions not working
920
- - "Not allowed" errors on operations that should work
921
-
922
- **Cause:** Features like phase transitions must be **enabled in workflow configuration** in Hailer. The SDK can't do operations that aren't configured.
923
-
924
- **Debug steps:**
925
- 1. Check Hailer UI: Workflow Settings → Phase settings
926
- 2. Verify phase transitions are configured (`possibleNextPhase`)
927
- 3. Verify user has permission to the workflow/phase
928
- 4. Check if the feature (move, archive, etc.) is enabled for that phase
929
-
930
- **Example:** Phase move fails because `possibleNextPhase` doesn't include the target phase.
931
- </troubleshooting>
588
+ **Replay storm guard:** skip signals arriving within ~3s of mount (`if (Date.now() - mountTimeRef.current < 3000) return`).
589
+ **Skeleton gate:** `{loading && rows.length === 0 ? <Skeleton /> : <DataTable />}` — don't blank on background refresh.
590
+ </real-time>
932
591
 
933
592
  <build-fixes>
934
593
  ## Common Build Errors
935
594
 
936
- ### Cannot find module '@hailer/app-sdk'
937
- ```typescript
938
- // WRONG
939
- import { useHailer } from '@hailer/app-sdk';
595
+ | Error | Fix |
596
+ |-------|-----|
597
+ | `Cannot find module '@hailer/app-sdk'` | `import useHailer from './hailer/use-hailer'` |
598
+ | `has no exported member 'useHailer'` | Use default import (not named) |
599
+ | `fields possibly undefined` | `activity.fields?.[fieldId]?.value` |
600
+ | Infinite re-render | `useEffect(..., [inside])` not `[hailer]` |
601
+ | Hooks order error | All hooks before any early returns |
602
+ | Hook in map/conditional | Move hook to component top level |
603
+ | `activity.move is not a function` | Use `activity.update([{_id, phaseId}], {})` |
940
604
 
941
- // CORRECT
942
- import useHailer from './hailer/use-hailer';
943
- ```
605
+ ### Routing
944
606
 
945
- ### has no exported member 'useHailer'
946
- ```typescript
947
- // WRONG - named import
948
- import { useHailer } from './hailer/use-hailer';
949
-
950
- // CORRECT - default import
951
- import useHailer from './hailer/use-hailer';
952
- ```
953
-
954
- ### fields possibly undefined
955
- ```typescript
956
- // WRONG
957
- const value = activity.fields[fieldId].value;
958
-
959
- // CORRECT
960
- const value = activity.fields?.[fieldId]?.value;
961
- ```
962
-
963
- ### Infinite re-render loop
964
- ```typescript
965
- // WRONG - hailer changes every render
966
- useEffect(() => { ... }, [hailer]);
967
-
968
- // CORRECT - inside is stable
969
- useEffect(() => { ... }, [inside]);
970
- ```
971
-
972
- ### Hooks order error
973
- ```typescript
974
- // WRONG - early return before hook
975
- if (!inside) return <Text>Error</Text>;
976
- const [data, setData] = useState([]); // Error!
977
-
978
- // CORRECT - hooks first, then early return
979
- const [data, setData] = useState([]);
980
- if (!inside) return <Text>Error</Text>;
981
- ```
982
-
983
- ### Hooks inside map() or conditionals
984
- ```typescript
985
- // WRONG - hook in map
986
- {items.map((item) => (
987
- <Tr _hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}>
988
- ))}
989
-
990
- // CORRECT - hook at top level
991
- const hoverBg = useColorModeValue('gray.50', 'gray.600');
992
- {items.map((item) => (
993
- <Tr _hover={{ bg: hoverBg }}>
994
- ))}
995
- ```
996
-
997
- ### hailer.activity.move is not a function
998
-
999
- `activity.move()` does not exist. Use `activity.update([{ _id, phaseId }], {})` — see Phase Transitions in sdk-api.
1000
-
1001
- ### Routing: HashRouter vs BrowserRouter
1002
-
1003
- **Most Hailer apps don't need routing at all.** Use state-based page switching:
607
+ Most Hailer apps don't need routing — use state-based switching:
1004
608
  ```typescript
1005
609
  const [page, setPage] = useState('home');
1006
610
  {page === 'home' && <HomePage />}
1007
- {page === 'settings' && <SettingsPage />}
1008
611
  ```
1009
612
 
1010
- **If you need routing** (bookmarkable URLs, back button, public apps):
1011
- ```typescript
1012
- // ✅ USE HashRouter - works in iframe, no server config
1013
- import { createHashRouter, RouterProvider } from 'react-router-dom';
1014
-
1015
- const router = createHashRouter([
1016
- { path: '/', element: <HomePage /> },
1017
- { path: '/settings', element: <SettingsPage /> },
1018
- ]);
613
+ **Multi-page apps: use HashRouter, not BrowserRouter.** The static CDN at `apps.hailer.com/<ws>/<appId>/` serves only `index.html`.
1019
614
 
1020
- // URLs look like: yourapp.com/#/settings
1021
- ```
615
+ ### localStorage scope per workspace+app
1022
616
 
617
+ `apps.hailer.com` is the shared origin for all deployed apps. Namespace every persisted key:
1023
618
  ```typescript
1024
- // ⚠️ AVOID BrowserRouter in iframe apps
1025
- // Can cause "No routes matched location /index.html" errors
1026
- // because iframe URL contains /index.html path
619
+ const storageKey = (key: string) => `${workspaceId}.${appId}.${key}`;
620
+ localStorage.setItem(storageKey('selectedStockId'), id);
1027
621
  ```
1028
622
 
1029
- **When to use routing:**
1030
- - Public apps with shareable URLs
1031
- - Complex multi-section apps (like hailer-admin)
1032
- - Apps where back button navigation matters
1033
- </build-fixes>
623
+ ### `isAdmin` from `useApp` starts as `undefined`
1034
624
 
1035
- <sdk-crud>
1036
- ## SDK Create/Update Formats
625
+ Guard `if (isAdmin === undefined) return <LoadingState/>` before any branch that gates on `isAdmin` — treating it as `false` before `permission.map()` completes causes admins to hit terminal branches before the flag flips.
1037
626
 
1038
- ### activity.create() Format
627
+ ### `v3.app.public` always returns 403
1039
628
 
1040
- **Signature:** `hailer.activity.create(workflowId, activities[], options)`
629
+ Endpoint is unimplemented in hailer-api. Workaround: settings workflow + public insight.
1041
630
 
1042
- **CRITICAL:** Takes array of activities, raw field values (not wrapped).
631
+ ### `vite build` does NOT type-check
1043
632
 
1044
- ```typescript
1045
- await hailer.activity.create(WORKFLOW_ID, [
1046
- {
1047
- name: 'Activity name',
1048
- fields: {
1049
- [FIELD_ID]: 'string or number value', // NOT wrapped in { value: ... }
1050
- [ACTIVITYLINK_FIELD]: 'linkedActivityId', // Just the ID, not { _id, name }
1051
- },
1052
- },
1053
- ], {});
1054
- ```
633
+ `npm run build` uses esbuild (transpile-only) — succeeds even when types are broken. Always run `npx tsc --noEmit` before declaring an app done.
1055
634
 
1056
- **Common mistakes:**
1057
- - Passing single object instead of array
1058
- - Wrapping field values in `{ value: ... }`
1059
- - Passing `{ _id, name }` for activitylinks instead of just ID
635
+ ### Inline date-editor silently does nothing (self-blur trap)
1060
636
 
1061
- ### activity.update() Format
637
+ Native `<input type="date" autoFocus onBlur={commit}>` self-blurs on programmatic mount — the blur fires synchronously during `autoFocus`, calling the commit handler with an empty value. Fix: use `editorKind:'text'` so the cell falls through to a plain text input.
1062
638
 
1063
- **Signature:** `hailer.activity.update(activities[], options)`
639
+ ### Wizard / Multi-Step Form — Single Staging Surface Rule
1064
640
 
1065
- ```typescript
1066
- await hailer.activity.update([
1067
- {
1068
- _id: 'activityId',
1069
- name: 'New name', // optional
1070
- fields: {
1071
- [FIELD_ID]: newValue,
1072
- },
1073
- phaseId: 'newPhaseId', // optional - move to different phase
1074
- },
1075
- ], {});
1076
- ```
1077
- </sdk-crud>
641
+ A two-zone model (separate "review zone" + staged panel) causes silent empty-commit bugs — the clerk forgets to click "Add all" and the final create call receives empty rows.
1078
642
 
1079
- <file-structure>
1080
- ## Required Files
643
+ **Rule: ONE staging surface.** When "Suggest rows" is clicked, push directly into the same `rows` state that the commit action reads from. Make the staged panel inline-editable.
644
+ </build-fixes>
1081
645
 
1082
- ```
1083
- src/
1084
- App.tsx # Main component (EDIT THIS)
1085
- main.tsx # Entry point (NEVER EDIT)
1086
- hailer/
1087
- use-hailer.ts # SDK hook (generated)
1088
- types/
1089
- index.ts # Type definitions (CREATE)
1090
- utils/
1091
- fields.ts # Field helpers (CREATE)
1092
- constants/
1093
- fields.ts # Field ID constants (CREATE)
1094
- ```
646
+ <eslint-policy>
647
+ ## ESLint — Required Before Done
1095
648
 
1096
- ## Constants File Pattern
649
+ Every Hailer app must have working ESLint and pass lint before the app is considered done.
1097
650
 
1098
- ```typescript
1099
- // src/constants/fields.ts
1100
- export const WORKFLOW_ID = 'workflowId123';
1101
- export const PHASE_ID = 'phaseId456';
1102
-
1103
- export const FIELDS = {
1104
- NAME: 'fieldId001',
1105
- STATUS: 'fieldId002',
1106
- DATE: 'fieldId003',
1107
- } as const;
651
+ ```bash
652
+ npm run lint # run before reporting done; lint errors block done
1108
653
  ```
1109
654
 
1110
- </file-structure>
1111
-
1112
- <app-manifest>
1113
- ## App Manifest Configuration
1114
-
1115
- The `manifest.json` file in app root defines configurable settings exposed in Hailer UI.
1116
-
1117
- ### config.fields Structure
1118
-
655
+ If the `lint` script is missing, add it:
1119
656
  ```json
1120
- {
1121
- "name": "My App",
1122
- "version": "1.0.0",
1123
- "config": {
1124
- "fields": {
1125
- "defaultView": {
1126
- "type": "string",
1127
- "label": "Default View",
1128
- "default": "month"
1129
- },
1130
- "slotMinTime": {
1131
- "type": "string",
1132
- "label": "Day Start Time",
1133
- "default": "08:00"
1134
- },
1135
- "showWeekends": {
1136
- "type": "boolean",
1137
- "label": "Show Weekends",
1138
- "default": true
1139
- },
1140
- "itemsPerPage": {
1141
- "type": "number",
1142
- "label": "Items Per Page",
1143
- "default": 25
1144
- },
1145
- "enabledFeatures": {
1146
- "type": "array",
1147
- "label": "Enabled Features",
1148
- "default": ["search", "export"]
1149
- }
1150
- }
1151
- }
1152
- }
1153
- ```
1154
-
1155
- ### Field Types
1156
-
1157
- | Type | Description | Example Default |
1158
- |------|-------------|-----------------|
1159
- | `string` | Text value | `"month"` |
1160
- | `number` | Numeric value | `25` |
1161
- | `boolean` | True/false toggle | `true` |
1162
- | `array` | List of values | `["a", "b"]` |
1163
-
1164
- ### Accessing Config in App
1165
-
1166
- Config values come through HailerApi callback when app loads inside Hailer:
1167
-
1168
- ```typescript
1169
- const { inside, hailer, config } = useHailer();
1170
-
1171
- // Access configured values
1172
- const defaultView = config?.defaultView ?? 'month';
1173
- const showWeekends = config?.showWeekends ?? true;
657
+ "scripts": { "lint": "eslint src --ext .ts,.tsx --max-warnings 0" }
1174
658
  ```
659
+ </eslint-policy>
1175
660
 
1176
- ### Best Practices
1177
-
1178
- **DO expose in config.fields:**
1179
- - User-customizable options (default views, display preferences)
1180
- - Workflow/phase IDs that vary per installation
1181
- - Feature toggles
1182
-
1183
- **DON'T expose in config.fields:**
1184
- - Values hardcoded in app code (wastes config UI space)
1185
- - Sensitive data (use environment variables)
1186
- - Internal constants that users shouldn't change
1187
-
1188
- **RULE:** If a config field is defined but the app ignores it, remove it from manifest.
1189
- </app-manifest>
1190
-
1191
- <public-api>
1192
- ## Public API (Apps Outside Hailer)
661
+ <file-handling>
662
+ ## File Handling
1193
663
 
1194
- For apps that run standalone (outside Hailer iframe) without authentication:
664
+ A file-modifier text field (`modifier.file: true`) stores a JSON-stringified array of file IDs — parse it, don't read it as a single ID.
1195
665
 
1196
- ```typescript
1197
- // Public insight data
1198
- const data = await hailer.public.insight.data(insightKey);
1199
- const objects = await hailer.public.insight.dataAsObject(insightKey);
1200
-
1201
- // Public forms
1202
- const formData = await hailer.public.form.data(formsKey);
1203
- const result = await hailer.public.form.submit(formsKey, formData);
666
+ `hailer.file.get().data` is a full `data:` URI, not raw base64 — use it directly as an `<img src>`.
1204
667
 
1205
- // Public app config
1206
- const config = await hailer.public.app.config();
668
+ **`fileIds` only bind at `activity.create`.** There is no retroactive attach in app-sdk. Defer activity creation until files are ready. See `hailer-apps-pictures` for the canonical upload pattern.
669
+ </file-handling>
1207
670
 
1208
- // Public products
1209
- const products = await hailer.public.product.list(filter?, options?);
1210
- const product = await hailer.public.product.get(productId);
1211
- ```
671
+ <ios-compat>
672
+ ## iOS Compatibility Default Target
1212
673
 
1213
- **When to use Public API:**
1214
- - Building external-facing apps (not in Hailer iframe)
1215
- - Public dashboards using insight keys
1216
- - Public form submissions
1217
- - No user authentication available
674
+ Build every app so it works on iOS unless a web-only capability is genuinely required.
1218
675
 
1219
- **Note:** Public APIs use keys (not IDs) and don't require authentication.
1220
- </public-api>
676
+ | Feature | Web | iOS |
677
+ |---------|-----|-----|
678
+ | `hailer.http.fetch('https://api.hailer.com/...')` | Works out-of-box | Requires `allowedUrls` to include `https://api.hailer.com` — default empty → 400 |
679
+ | `hailer.http.upload` | Available | Absent — use `hailer.file.upload()` instead |
680
+ | `ui.activity.editMultiple` | Available | Not supported |
681
+ | `ui.insight.create/edit/delete/permission` | Available | Not supported |
1221
682
 
1222
- <sdk-reference>
1223
- ## SDK Type Definitions (For Edge Cases)
683
+ When an app must use a web-only capability, state it explicitly: "This app will be web-only because it uses `http.upload`".
684
+ </ios-compat>
1224
685
 
1225
- The skill covers common SDK methods. For less common APIs, read the type definitions in any Hailer app project:
686
+ <vpc-reachability>
687
+ ## Apps Cannot Reach the Monolith
1226
688
 
1227
- ```
1228
- node_modules/@hailer/app-sdk/lib/modules/
1229
- ├── activity.d.ts - list, get, create, update, remove
1230
- ├── activity/kanban.d.ts - kanban.list, load, updatePriority
1231
- ├── user.d.ts - current, get, list
1232
- ├── ui.d.ts - snackbar, activity, insight, files
1233
- ├── ui/activity.d.ts - open, create, editMultiple
1234
- ├── ui/snackbar.d.ts - open
1235
- ├── ui/files.d.ts - uploadFile
1236
- ├── insight.d.ts - data, list, update
1237
- ├── process.d.ts - list, get (workflow API uses this)
1238
- ├── workspace.d.ts - current, list, product methods
1239
- ├── permission.d.ts - map
1240
- ├── app.d.ts - config.update, product methods
1241
- └── public/ - insight, form, product (no auth)
1242
- ```
689
+ Hailer apps run in the end user's browser, outside the microservice VPC. `monolith.hailer.com` is private-DNS only — `fetch('https://monolith.hailer.com/...')` fails at DNS resolution, not CORS.
1243
690
 
1244
- **When to check types:**
1245
- - Method not documented here → read the relevant `.d.ts` file
1246
- - Need method signature details → types are the source of truth
1247
- - New SDK version → types show what's available
1248
- </sdk-reference>
691
+ Backend patterns when an app needs server logic: activity-write + phase webhook, or a dedicated internet-facing microservice.
692
+ </vpc-reachability>