@lotics/app-sdk 0.77.4 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1 @@
1
- /**
2
- * Capture an app event. Buffers until PostHog is ready, then emits directly;
3
- * no-ops permanently once tracking is known to be off, so the hooks can call it
4
- * unconditionally.
5
- */
6
- export declare function captureAppEvent(event: string, props?: Record<string, unknown>): void;
7
1
  export declare function bootstrapAnalytics(): Promise<void>;
@@ -9,12 +9,20 @@
9
9
  * values — and Điều 3.2 of the service contract warrants that our telemetry
10
10
  * carries no record or document content. The flags below are what enforce
11
11
  * that; the project-level setting is NOT a backstop, since it is server-side
12
- * and fails open when the config fetch fails. The SDK emits explicit events
13
- * for genuine user actions only: `app_opened`, `app_file_uploaded`, and
14
- * `app_comment_*`. System signals data-read mechanics (a `useQuery` refetch)
15
- * and workflow/agent run outcomesare not events: the backend already logs
16
- * and persists every run (`workflow_executions`, `app_agent_runs`), so a client
17
- * event would be redundant system telemetry, not a gesture.
12
+ * and fails open when the config fetch fails.
13
+ *
14
+ * **What earns an event:** who KNOWS the fact. An effect the platform performs
15
+ * or persists is the platform's a comment, an upload, a workflow or agent run
16
+ * all leave rows, and a client copy would duplicate one AND keep arriving from
17
+ * apps pinned to an old SDK long after the code is deleted. A FAILURE is not a
18
+ * gesture either; it is system telemetry, and this bundle has no log sink to
19
+ * carry it, so it is not an event here by default.
20
+ *
21
+ * That leaves exactly one: `app_opened`. Apps are a separate origin the
22
+ * product's own analytics cannot see, and the app-host gate log records gate
23
+ * DECISIONS rather than routine serves, so nothing else counts an app being
24
+ * used. Everything a run does is already in `app_agent_runs` — including a
25
+ * user's Stop, which stamps `cancel_requested_at`.
18
26
  *
19
27
  * Every event is tagged with app identity and rolls up under the existing
20
28
  * `organization` group; embedded apps `identify` the member the host passes
@@ -33,34 +41,6 @@ import { hasMockFlag } from "./mock.js";
33
41
  const POSTHOG_KEY = "phc_N1nyqSRdo9XMK3DODxrxX2Y9jG3dppybruOuMznbz62";
34
42
  const POSTHOG_HOST = "https://us.i.posthog.com";
35
43
  const APP_HOST_SUFFIX = ".lotics.app";
36
- let loaded = false;
37
- let disabled = false;
38
- /**
39
- * Events fired before init completes (a hook can settle during the `context`
40
- * round-trip). Drained on init; capped so a never-initializing session can't
41
- * grow it unbounded.
42
- */
43
- const preInitQueue = [];
44
- const MAX_QUEUE = 100;
45
- function disable() {
46
- disabled = true;
47
- preInitQueue.length = 0;
48
- }
49
- /**
50
- * Capture an app event. Buffers until PostHog is ready, then emits directly;
51
- * no-ops permanently once tracking is known to be off, so the hooks can call it
52
- * unconditionally.
53
- */
54
- export function captureAppEvent(event, props) {
55
- if (disabled)
56
- return;
57
- if (loaded) {
58
- posthog.capture(event, props);
59
- return;
60
- }
61
- if (preInitQueue.length < MAX_QUEUE)
62
- preInitQueue.push({ event, props });
63
- }
64
44
  function onDeployedAppHost() {
65
45
  try {
66
46
  return window.location.hostname.endsWith(APP_HOST_SUFFIX);
@@ -73,17 +53,14 @@ export async function bootstrapAnalytics() {
73
53
  // Only the deployed app host tracks: a design-time/screenshot load
74
54
  // (?__mock=1) and `lotics app dev` (localhost) emit nothing. This replaces
75
55
  // the old "no key off-prod" gate now that the key is hardcoded.
76
- if (hasMockFlag() || !onDeployedAppHost()) {
77
- disable();
56
+ if (hasMockFlag() || !onDeployedAppHost())
78
57
  return;
79
- }
80
58
  let ctx;
81
59
  try {
82
60
  ctx = await rpc("context", {});
83
61
  }
84
62
  catch {
85
63
  // Best-effort: a context-resolution failure must never break the app.
86
- disable();
87
64
  return;
88
65
  }
89
66
  posthog.init(POSTHOG_KEY, {
@@ -113,9 +90,12 @@ export async function bootstrapAnalytics() {
113
90
  // Tag + capture from `loaded` (once PostHog has initialized) — the robust
114
91
  // point to register super-properties and emit the first event.
115
92
  loaded: (ph) => {
93
+ // Ids only. A super-property rides every event and every `$exception`
94
+ // the session sends, and an app's NAME is the customer's own words —
95
+ // routinely a counterparty — which Điều 3.2 keeps out of telemetry. The
96
+ // id resolves to the name for anyone entitled to it.
116
97
  ph.register({
117
98
  app_id: ctx.app_id,
118
- app_name: ctx.app_name,
119
99
  workspace_id: ctx.workspace_id,
120
100
  organization_id: ctx.organization_id,
121
101
  });
@@ -127,10 +107,7 @@ export async function bootstrapAnalytics() {
127
107
  // visitors stay anonymous (member_id null).
128
108
  if (ctx.member_id)
129
109
  ph.identify(ctx.member_id);
130
- loaded = true;
131
110
  ph.capture("app_opened");
132
- for (const e of preInitQueue.splice(0))
133
- ph.capture(e.event, e.props);
134
111
  },
135
112
  });
136
113
  }
@@ -25,7 +25,6 @@
25
25
  import { useCallback } from "react";
26
26
  import useSWR from "swr";
27
27
  import { rpc } from "./rpc.js";
28
- import { captureAppEvent } from "./analytics.js";
29
28
  import { useAppContext } from "./viewer.js";
30
29
  /** The reduced storage shape the update endpoint accepts for `files`. */
31
30
  function toStorageFiles(files) {
@@ -101,7 +100,6 @@ export function useComments(args) {
101
100
  revalidate: false,
102
101
  populateCache: true,
103
102
  });
104
- captureAppEvent("app_comment_created", { has_files: fileIds.length > 0 });
105
103
  }, [available, memberId, record_id, swr]);
106
104
  const updateComment = useCallback(async (id, input) => {
107
105
  if (!available)
@@ -129,7 +127,6 @@ export function useComments(args) {
129
127
  revalidate: false,
130
128
  populateCache: true,
131
129
  });
132
- captureAppEvent("app_comment_updated", {});
133
130
  }, [available, record_id, swr]);
134
131
  const deleteComment = useCallback(async (id) => {
135
132
  if (!available)
@@ -145,7 +142,6 @@ export function useComments(args) {
145
142
  revalidate: false,
146
143
  populateCache: true,
147
144
  });
148
- captureAppEvent("app_comment_deleted", {});
149
145
  }, [available, record_id, swr]);
150
146
  return {
151
147
  comments,
package/dist/src/hooks.js CHANGED
@@ -22,7 +22,6 @@ import useSWRInfinite from "swr/infinite";
22
22
  import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
23
23
  import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, pendingInteractiveCall, buildChoiceOutput, applyInteractiveAnswer, landingOf, proseOf, ABORTED, } from "./agent_stream.js";
24
24
  import { getMockRows, hasMockFlag } from "./mock.js";
25
- import { captureAppEvent } from "./analytics.js";
26
25
  export { buildChoiceOutput } from "./agent_stream.js";
27
26
  export function useWorkflow(alias) {
28
27
  return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
@@ -288,7 +287,6 @@ export function useFileUpload() {
288
287
  setError(null);
289
288
  try {
290
289
  const uploaded = await rpc("upload", { file, fidelity });
291
- captureAppEvent("app_file_uploaded", { mime_type: file.type, fidelity });
292
290
  return uploaded;
293
291
  }
294
292
  catch (err) {
@@ -601,14 +599,6 @@ export function useAgentRun(alias) {
601
599
  error: "The run was interrupted and its result could not be confirmed. Run it again.",
602
600
  };
603
601
  safeSetState(acc);
604
- // Fleet visibility: how often edges cut agent SSE streams. One
605
- // event per truncation, with whether the row rescued the result.
606
- captureAppEvent("app_agent_stream_truncated", {
607
- kind: "clean_end",
608
- recovered: settled != null,
609
- settled_status: settled?.status ?? null,
610
- has_output: acc.output !== undefined,
611
- });
612
602
  }
613
603
  return landingOf(acc);
614
604
  })
@@ -635,16 +625,6 @@ export function useAgentRun(alias) {
635
625
  acc = adoptGuarded(acc, settled);
636
626
  safeSetState(acc);
637
627
  }
638
- // The same fleet-visibility beacon as the clean-end path — a dropped
639
- // connection is the OTHER way a stream dies mid-run, and it was
640
- // previously invisible (the beacon fired only on clean-end
641
- // truncations, undercounting every network-error cut).
642
- captureAppEvent("app_agent_stream_truncated", {
643
- kind: "connection_error",
644
- recovered: settled != null,
645
- settled_status: settled?.status ?? null,
646
- has_output: acc.output !== undefined,
647
- });
648
628
  if (settled)
649
629
  return landingOf(acc);
650
630
  }
@@ -663,7 +643,6 @@ export function useAgentRun(alias) {
663
643
  // in-flight promise makes a double-click resolve with the first run's
664
644
  // result; `replace: true` is the explicit opt-in to abort-and-restart.
665
645
  if (inflightRef.current && !opts.replace) {
666
- captureAppEvent("app_agent_run_deduped", { alias });
667
646
  return inflightRef.current;
668
647
  }
669
648
  handleRef.current?.abort();
@@ -710,6 +689,10 @@ export function useAgentRun(alias) {
710
689
  const abort = useCallback(() => handleRef.current?.abort(), []);
711
690
  const cancel = useCallback(() => {
712
691
  // Stop server-side too (saves tokens on an unwanted run), then locally.
692
+ // No analytics event here: the cancel RPC stamps `app_agent_runs
693
+ // .cancel_requested_at`, which is what separates a user's Stop from the
694
+ // unmount/tab-close that `abort` also serves — a client event would only
695
+ // duplicate a column, and would go stale the moment an app pins an old SDK.
713
696
  const runId = runIdRef.current;
714
697
  if (runId)
715
698
  void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
@@ -18,10 +18,24 @@
18
18
  * not tell which. That is why no register row ever rendered an avatar — the
19
19
  * data was absent and nothing said so.
20
20
  *
21
- * The private fields share ONE boundary, not three: an authenticated member of
22
- * the app's own org sees `email`, `image` and `groups`; an anonymous visitor to
23
- * a public app sees `id` and `name`. Absent ≠ empty — `image: null` means the
24
- * member has no photo, `image` MISSING means you were never told.
21
+ * It happened a second time, and the fix is the same: the roster returned
22
+ * neither `groups` nor `role` long after a resolved cell carried both, so an
23
+ * app reading `m.groups` off `useMembers()` got `undefined` and drew nothing.
24
+ * Read a field here and you may read it on either door.
25
+ *
26
+ * The private fields share ONE boundary, not four: an authenticated member of
27
+ * the app's own org sees `email`, `image`, `groups` and `role`; an anonymous
28
+ * visitor to a public app sees `id` and `name` (plus `archived`, which is not
29
+ * private — see the field). Absent ≠ empty — `image: null` means the member has
30
+ * no photo, `image` MISSING means you were never told, and `groups: []` means
31
+ * they are on no team while a missing `groups` means the same "not told". Never
32
+ * collapse the two: one is a fact about a colleague, the other is a fact about
33
+ * your own permissions.
34
+ *
35
+ * The one field the doors legitimately differ on is `archived`, and it is a
36
+ * difference of ROW SET rather than of shape: a resolved cell must keep naming
37
+ * whoever handled a record two years ago, while the roster answers "who may I
38
+ * assign?" and never offers a departed member at all.
25
39
  */
26
40
  export interface ResolvedMember {
27
41
  id: string;
@@ -40,6 +54,47 @@ export interface ResolvedMember {
40
54
  * department field; a member group is what an org uses to say Sale, Kế toán,
41
55
  * CSKH. `[]` when the member is in none. Omitted on public-app responses. */
42
56
  groups?: string[];
57
+ /**
58
+ * The member's ORGANIZATION role. Omitted on public-app responses, and also
59
+ * when the id did not resolve — there is no member to have a level.
60
+ *
61
+ * It arrives RAW, and it is your job to translate it: the platform ships no
62
+ * display word for it, because a server that picked one would leak English
63
+ * into every localized app. Map it yourself (`admin` → "Quản trị viên") next
64
+ * to the rest of your vocabulary.
65
+ *
66
+ * It is a PERMISSION level and not a job title. Everyone who reads "Admin"
67
+ * beside a name on a sales register will read it as rank; if the question
68
+ * your screen answers is "who is this person in the company", the answer is
69
+ * `groups`, not this.
70
+ */
71
+ role?: "owner" | "admin" | "member";
72
+ /**
73
+ * ISO timestamp of when this person joined the organization. Omitted on
74
+ * public-app responses, and when the id did not resolve — there is no
75
+ * membership to have begun.
76
+ *
77
+ * Render it at whatever precision your question needs; `@lotics/ui`'s
78
+ * `MemberProfileCard` shows month and year, because "is this the new person?"
79
+ * does not want a day.
80
+ */
81
+ joined?: string;
82
+ /**
83
+ * `true` when this person has LEFT the organization — omitted otherwise,
84
+ * never `false`, because it rides every cell of every row and current staff
85
+ * are the overwhelming case.
86
+ *
87
+ * It arrives on both audiences: a departed colleague reading as a current
88
+ * assignee is wrong on a public app too, and it discloses less than the name
89
+ * already beside it. Feed it to `inactive` on `MemberChip` /
90
+ * `MemberProfileCard` so a record that still names them reads as history
91
+ * rather than as a live assignment.
92
+ *
93
+ * You will not see it on the `useMembers` roster, and that is correct rather
94
+ * than missing: the roster answers "who may I ASSIGN?" and departed members
95
+ * are not candidates, so it never returns one.
96
+ */
97
+ archived?: true;
43
98
  }
44
99
  /**
45
100
  * Parse a `useQuery` cell value into `ResolvedMember[]`. Returns `[]` for
@@ -41,6 +41,21 @@ export function readMembers(value) {
41
41
  ? obj.groups.filter((g) => typeof g === "string")
42
42
  : [];
43
43
  }
44
+ // A CLOSED enum, so an unrecognized value is dropped rather than passed
45
+ // through: the field's whole worth is that a caller can switch on it, and a
46
+ // server that grew a fourth role must not have apps rendering the raw word
47
+ // in a UI that has no translation for it.
48
+ if (obj.role === "owner" || obj.role === "admin" || obj.role === "member")
49
+ m.role = obj.role;
50
+ // A non-empty STRING or nothing: an empty date is not a date, and letting
51
+ // "" through would render an empty labelled row rather than no row.
52
+ if (typeof obj.joined === "string" && obj.joined !== "")
53
+ m.joined = obj.joined;
54
+ // The server sends this ONLY for a departed member and only as `true`, so
55
+ // the truthy test is the whole contract — there is no `false` to carry, and
56
+ // inventing one would put the key on every current member's cell.
57
+ if (obj.archived === true)
58
+ m.archived = true;
44
59
  out.push(m);
45
60
  }
46
61
  return out;
package/docs/ai.md CHANGED
@@ -57,8 +57,8 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
57
57
 
58
58
  | Member | Type | What it is |
59
59
  |---|---|---|
60
- | `run` | `(input, { sessionId, replace? }) => Promise<AgentRunLanding<TOutput>>` | Start a run. Streams progress into the hook's state and resolves with **how the leg ended** — `{kind:"settled", output?, text}`, `{kind:"parked"}`, `{kind:"failed", error}` or `{kind:"aborted"}`. Switch on `kind`; do NOT read the hook's state to tell them apart, because it has not committed when the promise resolves (that race closed dialogs over live, answerable runs). A run failure is DATA here, so no try/catch is needed for it. **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one (each dedup emits the `app_agent_run_deduped` analytics event). Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
61
- | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
60
+ | `run` | `(input, { sessionId, replace? }) => Promise<AgentRunLanding<TOutput>>` | Start a run. Streams progress into the hook's state and resolves with **how the leg ended** — `{kind:"settled", output?, text}`, `{kind:"parked"}`, `{kind:"failed", error}` or `{kind:"aborted"}`. Switch on `kind`; do NOT read the hook's state to tell them apart, because it has not committed when the promise resolves (that race closed dialogs over live, answerable runs). A run failure is DATA here, so no try/catch is needed for it. **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one. Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
61
+ | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this. The platform stamps `app_agent_runs.cancel_requested_at`, which is what distinguishes a user Stop from the unmount that `abort` also serves — so there is no client analytics event for it |
62
62
  | `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
63
63
  | `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
64
64
  | `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard` (`{question, options: {label, description}[], allow_custom}`) |
@@ -184,7 +184,7 @@ The run's lifetime is decoupled from the stream: the server drives it to complet
184
184
  - **The connection drops with an error** → the hook polls the persisted run to completion (every 2.5 s) and resolves with the settled result instead of surfacing a network error.
185
185
  - **The stream ends cleanly WITHOUT a `finish` frame** — an edge/proxy can close a long SSE gracefully mid-run, which looks like completion but swallowed the trailing frames (including the structured result). The hook detects the missing `finish` and polls the row the same way.
186
186
 
187
- Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`kind: "connection_error" | "clean_end"`, with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
187
+ Either way the run itself is unaffected it keeps executing server-side and settles in `app_agent_runs`, which is where its outcome is read.
188
188
 
189
189
  **Anonymous runs poll with a capability token, handled for you.** On a publicly-shared app a visitor has no member identity, so the server cannot authorize their poll by ownership — `triggered_by_member_id` is null by design. Instead the run response carries a per-run token (`x-app-agent-run-token`); the SDK stores it against that run id and replays it on the poll, on `cancel`, and on `answerChoice`. It is transport-level, like the password-session token — never surfaced to app code, and never issued for a member run (identity already authorizes those). Nothing to wire: an app calls `useAgentRun` the same way on both. The token is bound to ONE run, so it cannot read another visitor's, and it is why a dropped connection on a public app recovers rather than losing the answer.
190
190
 
@@ -108,13 +108,13 @@ cell's own `{ key, label }`, which renders as a neutral badge. Never hand-map op
108
108
  ## Member cells (`readMembers`)
109
109
 
110
110
  A `select_member` column's storage shape is a bare array of member ids. The server rewrites every
111
- projected `select_member` cell to `Array<{ id, name, email?, image?, groups? }>` — **the same shape
112
- `useMembers` returns**, so a member is a member wherever you got them from:
111
+ projected `select_member` cell to `Array<{ id, name, email?, image?, groups?, role?, joined?, archived? }>` —
112
+ **the same shape `useMembers` returns**, so a member is a member wherever you got them from:
113
113
 
114
- - **`email`, `image` and `groups` share ONE gate.** All three are present only for authenticated
115
- members of the app's own organization. Anonymous visitors to a public app — and members of *other*
116
- orgs viewing a publicly shared app — get `{ id, name }` only. A face and a team are org-internal
117
- exactly as an address is.
114
+ - **`email`, `image`, `groups`, `role` and `joined` share ONE gate.** All five are present only for
115
+ authenticated members of the app's own organization. Anonymous visitors to a public app — and
116
+ members of *other* orgs viewing a publicly shared app — get `{ id, name }` only. A face, a team
117
+ and a permission level are org-internal exactly as an address is.
118
118
  - **Absent is not null.** A missing `image` key means you were not told; `image: null` means the
119
119
  member genuinely has no photo. Same for `groups`: absent vs `[]`. Rendering the two the same way
120
120
  turns a permission boundary into a missing-data bug.
@@ -122,6 +122,18 @@ projected `select_member` cell to `Array<{ id, name, email?, image?, groups? }>`
122
122
  cookie-authenticated proxy, so an unsigned URL would not load inside an app iframe at all.
123
123
  - **`groups` is the platform's "department".** There is no department field; a member group is what
124
124
  an org uses to say Sale, Kế toán, CSKH. `[]` when the member is in none.
125
+ - **`role` arrives RAW (`owner` / `admin` / `member`) and you translate it.** The platform ships no
126
+ display word, because a server that picked one would leak English into every localized app — map
127
+ it next to the rest of your vocabulary. It is a PERMISSION level, not a job title: everyone reads
128
+ "Admin" beside a name as rank. If the question your screen asks is "who is this person in the
129
+ company", the answer is `groups`.
130
+ - **`joined` is an ISO timestamp of when the membership began.** Render it at whatever precision
131
+ your question needs — `@lotics/ui`'s `MemberProfileCard` shows month and year, because "is this
132
+ the new person?" does not want a day. Absent on a public response, and on an id that did not
133
+ resolve: there is no membership to have begun.
134
+ - **`archived: true` marks someone who has LEFT** — omitted otherwise, never `false`. It is the one
135
+ field that is NOT gated (a departed colleague reading as a current assignee is wrong on a public
136
+ app too). Feed it to `inactive` on `MemberChip` / `MemberProfileCard`.
125
137
  - **An id that no longer resolves** (removed member, id outside the org) comes back with
126
138
  `name: null` — an explicit missing state, never an empty string. Render a placeholder.
127
139
  - Only ids already present in the projected rows are resolved — a member cell never exposes the
@@ -139,7 +151,10 @@ const { members, loading, error } = useMembers({ group: "grp_fulfillment" });
139
151
  <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
140
152
  ```
141
153
 
142
- Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups }`.
154
+ Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups, role, joined }`.
155
+ The one field it never carries is `archived`, and that is the answer rather than a gap: this roster
156
+ answers "who may I ASSIGN?", and a departed member is not a candidate, so it never returns one. A
157
+ resolved CELL is the display door and keeps naming them.
143
158
  `image` is the avatar URL (a presigned URL valid 24 hours, or the member's external OAuth photo) and
144
159
  may be `null`. `name` may be null/empty for members without a display name — fall back to `email`. On failure the hook does not throw: it
145
160
  resolves `{ members: [], loading: false, error }`.
@@ -311,7 +326,7 @@ The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter
311
326
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
312
327
  | --- | --- | --- |
313
328
  | `readSelect` cell enrichment | ✓ | ✓ |
314
- | `readMembers` cell enrichment | ✓ (email + image + groups, same-org) | ✓ name-only |
329
+ | `readMembers` cell enrichment | ✓ (email + image + groups + role + joined, same-org) | ✓ name-only (+ `archived`) |
315
330
  | `useFieldOptions` | ✓ | ✓ |
316
331
  | `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
317
332
  | `useViewer` | member id (view-as target) | `null` |
package/docs/runtime.md CHANGED
@@ -344,37 +344,29 @@ await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
344
344
  ## Automatic analytics (PostHog)
345
345
 
346
346
  `mount()` boots a PostHog instance per app — apps are a separate cross-origin
347
- bundle, invisible to the product's own analytics. **No per-app wiring**: don't
348
- install `posthog-js` or call any analytics API from app code.
349
-
350
- - **Explicit events only — user gestures.** Autocapture, pageviews, and session
351
- replay are off (autocapture is disabled at the project level — a client flag
352
- could not re-enable it). System signals are deliberately not events: data
353
- reads (`useQuery` fetches) and workflow/agent run outcomes. The backend
354
- already logs and persists every run (`workflow_executions`, `app_agent_runs`).
355
- - Events the SDK emits automatically:
356
-
357
- | Event | Fired when | Properties |
358
- |---|---|---|
359
- | `app_opened` | analytics finished initializing after `mount()` | — |
360
- | `app_file_uploaded` | a `useFileUpload` upload succeeds | `mime_type` |
361
- | `app_comment_created` | a comment is created via `useComments` | `has_files` |
362
- | `app_comment_updated` / `app_comment_deleted` | comment edit/delete succeeds | — |
363
-
364
- - Every event carries the app identity as super-properties (`app_id`,
365
- `app_name`, `workspace_id`, `organization_id`) and rolls up under the
366
- `organization` group. Embedded apps `identify` the signed-in member — app and
347
+ bundle, invisible to the product's own analytics. It is entirely automatic:
348
+ don't install `posthog-js` or call any analytics API from app code, and there is
349
+ no hook for app-defined events. If a bespoke funnel matters, request the surface
350
+ as a platform change.
351
+
352
+ The SDK emits one event, `app_opened`, once PostHog finishes initializing.
353
+ Everything an app *does* is already recorded by the platform — a comment, an
354
+ upload, a workflow or agent run each leave a row, and a run's outcome, including
355
+ a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`.
356
+
357
+ - Autocapture, pageviews, dead clicks, and session replay are all off, set at
358
+ init rather than relied on project-wide: the server-side setting fails open,
359
+ and an app screen renders the customer's own records.
360
+ - `app_opened` carries the app identity as super-properties `app_id`,
361
+ `workspace_id`, `organization_id`, ids only, since a name is the customer's
362
+ own words — and rolls up under the `organization` group. Embedded apps `identify` the signed-in member app and
367
363
  product events share one person; standalone visitors stay anonymous.
368
364
  - Uncaught exceptions are captured (PostHog error tracking) in addition to
369
- `mount()`'s visible banner.
365
+ `mount()`'s visible banner — the app's one error channel.
370
366
  - **Tracking is gated to the deployed app host** (`*.lotics.app`). `lotics app
371
367
  dev` (localhost) and any `?__mock=1` load emit nothing. Best-effort
372
- throughout: a failed init or a failed `context` resolution disables tracking
373
- and never breaks the app. Events fired before init completes are buffered
374
- (bounded) and drained on init.
375
- - **Limitation:** there is no public API for custom app events — the capture
376
- function is internal to the SDK. If a bespoke funnel matters, request the
377
- surface as a platform change.
368
+ throughout: a failed init or a failed `context` resolution leaves the app
369
+ fully working and untracked.
378
370
  - **Limitation:** PostHog's default bot/user-agent filter applies — headless
379
371
  browsers (e.g. Playwright) are never tracked, so analytics cannot be verified
380
372
  through headless automation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.77.4",
3
+ "version": "0.79.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {