@lotics/app-sdk 0.84.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/dist/src/index.d.ts +2 -4
- package/dist/src/index.js +1 -2
- package/dist/src/mock.d.ts +1 -1
- package/dist/src/mock.js +1 -1
- package/dist/src/mount.d.ts +1 -1
- package/dist/src/mount.js +0 -6
- package/dist/src/new_record.d.ts +7 -2
- package/dist/src/new_record.js +1 -1
- package/dist/src/rpc.d.ts +10 -55
- package/dist/src/rpc.js +23 -59
- package/dist/src/use_optimistic.d.ts +8 -3
- package/dist/src/viewer.d.ts +1 -3
- package/dist/src/viewer.js +1 -7
- package/docs/ai.md +4 -2
- package/docs/data_fetching.md +9 -0
- package/docs/mutations.md +45 -16
- package/docs/runtime.md +18 -109
- package/docs/workflows.md +27 -1
- package/package.json +1 -2
- package/dist/src/analytics.d.ts +0 -1
- package/dist/src/analytics.js +0 -113
- package/dist/src/config.d.ts +0 -24
- package/dist/src/config.js +0 -33
package/AGENTS.md
CHANGED
|
@@ -24,7 +24,7 @@ signature; open the file.**
|
|
|
24
24
|
| [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
|
|
25
25
|
| [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
|
|
26
26
|
| [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
|
|
27
|
-
| [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`downloadFile`, geofencing,
|
|
27
|
+
| [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
|
|
28
28
|
|
|
29
29
|
## Non-negotiables (each detailed in its doc)
|
|
30
30
|
|
package/dist/src/index.d.ts
CHANGED
|
@@ -21,12 +21,10 @@ export type { QueryRow, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptio
|
|
|
21
21
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
22
22
|
export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
|
|
23
23
|
export { useViewer } from "./viewer.js";
|
|
24
|
-
export { useConfig } from "./config.js";
|
|
25
|
-
export type { AppConfigValue } from "./rpc.js";
|
|
26
24
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
27
25
|
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
|
28
|
-
export { rpc, isEmbedded
|
|
29
|
-
export type { RpcOp,
|
|
26
|
+
export { rpc, isEmbedded } from "./rpc.js";
|
|
27
|
+
export type { RpcOp, AiContextValue, AiContextRecordRef } from "./rpc.js";
|
|
30
28
|
export { openExternal } from "./open_external.js";
|
|
31
29
|
export { askAi, type AskAiArgs } from "./ask_ai.js";
|
|
32
30
|
export { downloadFile } from "./download.js";
|
package/dist/src/index.js
CHANGED
|
@@ -18,9 +18,8 @@ export { mount } from "./mount.js";
|
|
|
18
18
|
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
|
|
19
19
|
export { useComments, useCommentCounts } from "./comments.js";
|
|
20
20
|
export { useViewer } from "./viewer.js";
|
|
21
|
-
export { useConfig } from "./config.js";
|
|
22
21
|
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
23
|
-
export { rpc, isEmbedded
|
|
22
|
+
export { rpc, isEmbedded } from "./rpc.js";
|
|
24
23
|
export { openExternal } from "./open_external.js";
|
|
25
24
|
export { askAi } from "./ask_ai.js";
|
|
26
25
|
export { downloadFile } from "./download.js";
|
package/dist/src/mock.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export declare function registerMockFixture(fixture: AppFixture | undefined): vo
|
|
|
59
59
|
/**
|
|
60
60
|
* True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
|
|
61
61
|
* a malformed URL or missing `window` (SSR / jsdom without location) silently
|
|
62
|
-
* returns false. Distinct from `isMockMode`:
|
|
62
|
+
* returns false. Distinct from `isMockMode`: a caller may key off the raw flag
|
|
63
63
|
* (a design-time / screenshot load emits no events regardless of fixtures),
|
|
64
64
|
* while query mocking additionally requires a registered fixture.
|
|
65
65
|
*/
|
package/dist/src/mock.js
CHANGED
|
@@ -48,7 +48,7 @@ export function registerMockFixture(fixture) {
|
|
|
48
48
|
/**
|
|
49
49
|
* True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
|
|
50
50
|
* a malformed URL or missing `window` (SSR / jsdom without location) silently
|
|
51
|
-
* returns false. Distinct from `isMockMode`:
|
|
51
|
+
* returns false. Distinct from `isMockMode`: a caller may key off the raw flag
|
|
52
52
|
* (a design-time / screenshot load emits no events regardless of fixtures),
|
|
53
53
|
* while query mocking additionally requires a registered fixture.
|
|
54
54
|
*/
|
package/dist/src/mount.d.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* `mount` wires up React 19's createRoot against `#root` in the iframe shell,
|
|
28
28
|
* sets up window.onerror/unhandledrejection forwarding so runtime crashes
|
|
29
29
|
* surface in the parent's debug pane, registers the fixture (if any), renders
|
|
30
|
-
* the user's tree
|
|
30
|
+
* the user's tree.
|
|
31
31
|
*
|
|
32
32
|
* If the bundler doesn't ship #root in the user's `index.html`, we create it
|
|
33
33
|
* — Vite's default scaffold provides one, but defensive creation keeps the
|
package/dist/src/mount.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
2
|
import { registerMockFixture } from "./mock.js";
|
|
3
|
-
import { bootstrapAnalytics } from "./analytics.js";
|
|
4
3
|
export function mount(element, options = {}) {
|
|
5
4
|
registerMockFixture(options.fixture);
|
|
6
5
|
let container = document.getElementById("root");
|
|
@@ -14,11 +13,6 @@ export function mount(element, options = {}) {
|
|
|
14
13
|
// *something* even before the parent's debug telemetry is wired up.
|
|
15
14
|
installVisibleErrorHandlers(container);
|
|
16
15
|
createRoot(container).render(element);
|
|
17
|
-
// Fire-and-forget: resolve the app's identity and start PostHog (explicit
|
|
18
|
-
// events — autocapture is disabled project-wide). Never awaited — analytics
|
|
19
|
-
// must not delay first paint, and is best-effort so its failure can't break
|
|
20
|
-
// the app. No-ops in mock mode and off the deployed app host.
|
|
21
|
-
void bootstrapAnalytics();
|
|
22
16
|
}
|
|
23
17
|
function installVisibleErrorHandlers(container) {
|
|
24
18
|
const showError = (message) => {
|
package/dist/src/new_record.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export interface NewRecordApi<P> {
|
|
|
56
56
|
* const { id, save } = useNewRecord({
|
|
57
57
|
* create: (id, patch) => createCustomer({ record_id: id, ...patch }),
|
|
58
58
|
* update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
|
|
59
|
-
* onCreated:
|
|
59
|
+
* onCreated: (id) => select(id), // it exists now — the list re-reads itself
|
|
60
60
|
* });
|
|
61
61
|
* <InlineText onBlur={(name) => save({ name })} />
|
|
62
62
|
* ```
|
|
@@ -64,6 +64,11 @@ export interface NewRecordApi<P> {
|
|
|
64
64
|
export declare function useNewRecord<P>(opts: {
|
|
65
65
|
create: (id: string, patch: P) => Promise<unknown>;
|
|
66
66
|
update: (id: string, patch: P) => Promise<unknown>;
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Runs once, after the record first exists, with its id. NOT for refetching a
|
|
69
|
+
* list — the `create` workflow's own success already re-read it. This is for
|
|
70
|
+
* what only the id can drive: routing to the record, selecting it, dropping the
|
|
71
|
+
* surface's "new" state.
|
|
72
|
+
*/
|
|
68
73
|
onCreated?: (id: string) => void;
|
|
69
74
|
}): NewRecordApi<P>;
|
package/dist/src/new_record.js
CHANGED
|
@@ -56,7 +56,7 @@ export function newRecordId() {
|
|
|
56
56
|
* const { id, save } = useNewRecord({
|
|
57
57
|
* create: (id, patch) => createCustomer({ record_id: id, ...patch }),
|
|
58
58
|
* update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
|
|
59
|
-
* onCreated:
|
|
59
|
+
* onCreated: (id) => select(id), // it exists now — the list re-reads itself
|
|
60
60
|
* });
|
|
61
61
|
* <InlineText onBlur={(name) => save({ name })} />
|
|
62
62
|
* ```
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
|
20
20
|
* app → host: { id, op, payload }
|
|
21
21
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
22
22
|
*/
|
|
23
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "
|
|
23
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
24
24
|
/** Payload for starting a streaming agent run. */
|
|
25
25
|
export interface AgentRunPayload {
|
|
26
26
|
alias: string;
|
|
@@ -37,24 +37,11 @@ export interface AgentRunHandle {
|
|
|
37
37
|
* The app's identity, resolved once at startup to tag PostHog events.
|
|
38
38
|
* Assembled by whichever transport is active:
|
|
39
39
|
*
|
|
40
|
-
* - **Bridged** — the host (authenticated) supplies `member_id
|
|
41
|
-
*
|
|
42
|
-
* from its own context.
|
|
40
|
+
* - **Bridged** — the host (authenticated) supplies `member_id`, plus
|
|
41
|
+
* org/workspace/app from its own context.
|
|
43
42
|
* - **Standalone** — the public `/by-subdomain` endpoint returns identity;
|
|
44
43
|
* `member_id` is null (anonymous visitor).
|
|
45
|
-
*
|
|
46
|
-
* The PostHog key/host are not here — they're the public project key, hardcoded
|
|
47
|
-
* in `analytics.ts`.
|
|
48
|
-
*/
|
|
49
|
-
/**
|
|
50
|
-
* A single installation config value. An app package declares typed config knobs
|
|
51
|
-
* (labels, toggles, theme, column choices) with defaults; an installation stores
|
|
52
|
-
* the customized values, which the SDK's `useConfig()` reads. The canonical type
|
|
53
|
-
* lives in `@lotics/shared` (`AppInstallationConfigValue`); the SDK mirrors it
|
|
54
|
-
* here so it stays a zero-internal-dependency published package (the boundary
|
|
55
|
-
* mirror, like `AppContext` itself).
|
|
56
44
|
*/
|
|
57
|
-
export type AppConfigValue = string | number | boolean;
|
|
58
45
|
/** A raw reference to one record — table + record id, passed through UNRESOLVED.
|
|
59
46
|
* The member's own chat agent may act on it only where that member's IAM already
|
|
60
47
|
* allows; the app never resolves it into data here. */
|
|
@@ -87,10 +74,6 @@ export type HostNotification = {
|
|
|
87
74
|
context: AiContextValue | null;
|
|
88
75
|
};
|
|
89
76
|
export interface AppContext {
|
|
90
|
-
app_id: string;
|
|
91
|
-
app_name: string;
|
|
92
|
-
workspace_id: string;
|
|
93
|
-
organization_id: string;
|
|
94
77
|
member_id: string | null;
|
|
95
78
|
/**
|
|
96
79
|
* Whether the app declared the `comments` capability. `useComments` is
|
|
@@ -98,13 +81,6 @@ export interface AppContext {
|
|
|
98
81
|
* app that didn't opt in, and (vacuously) for standalone visitors.
|
|
99
82
|
*/
|
|
100
83
|
comments_enabled: boolean;
|
|
101
|
-
/**
|
|
102
|
-
* The installation's customization values — the package's config knobs by
|
|
103
|
-
* alias (see `useConfig`). Empty for a bespoke app (no package) and until the
|
|
104
|
-
* context resolves. Read-only; an app changes config through the product, not
|
|
105
|
-
* the SDK.
|
|
106
|
-
*/
|
|
107
|
-
config: Record<string, AppConfigValue>;
|
|
108
84
|
}
|
|
109
85
|
/**
|
|
110
86
|
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
@@ -200,11 +176,13 @@ export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
|
|
|
200
176
|
/** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
|
|
201
177
|
export declare const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
|
|
202
178
|
/**
|
|
203
|
-
* The error message for a non-ok response. A
|
|
204
|
-
* a `message
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* `
|
|
179
|
+
* The error message for a non-ok response. A JSON error the API AUTHORED surfaces
|
|
180
|
+
* verbatim — a 4xx carrying a `message`, or a 5xx that also carries `code`, the
|
|
181
|
+
* discriminator every error the API emits is built with. A non-JSON body (a
|
|
182
|
+
* gateway HTML page), a 5xx from something that is not us, or a body without a
|
|
183
|
+
* `message` falls back to a body-free, status-derived message — so a raw HTML
|
|
184
|
+
* body never becomes the message. `parsed` is the JSON.parse of the body, or
|
|
185
|
+
* `null` if it wasn't JSON.
|
|
208
186
|
*/
|
|
209
187
|
export declare function transportErrorMessage(status: number, parsed: unknown): string;
|
|
210
188
|
/**
|
|
@@ -217,26 +195,3 @@ export declare function transportErrorMessage(status: number, parsed: unknown):
|
|
|
217
195
|
* message, and a non-JSON or 5xx body never becomes one.
|
|
218
196
|
*/
|
|
219
197
|
export declare function streamStartError(res: Response): Promise<Error>;
|
|
220
|
-
/**
|
|
221
|
-
* A package installation's alias→concrete-id maps — what the generated
|
|
222
|
-
* `.lotics/app_fields.ts` of a package project resolves `F`/`OPT`/`ROLE`
|
|
223
|
-
* through at module load. Keys are fully-qualified contract aliases
|
|
224
|
-
* (`entity.field`, `entity.field:opt`, role alias); values are this
|
|
225
|
-
* installation's concrete ids. 404s for a bespoke (non-package) app.
|
|
226
|
-
*/
|
|
227
|
-
export interface AppBinding {
|
|
228
|
-
fields: Record<string, string>;
|
|
229
|
-
options: Record<string, string>;
|
|
230
|
-
roles: Record<string, string>;
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Fetch the installation's binding, once per boot (module-cached — every
|
|
234
|
-
* `app_fields` import shares the same in-flight promise). Called at module
|
|
235
|
-
* load via top-level await, so it must work before `mount()` — and a failure
|
|
236
|
-
* there rejects the whole module graph (blank frame, no ErrorBoundary can
|
|
237
|
-
* catch module evaluation). Two mitigations, mirroring `boot()`:
|
|
238
|
-
* bounded retries absorb a transient transport blip, and a rejection is never
|
|
239
|
-
* cached so a remount/direct caller can retry rather than replaying the same
|
|
240
|
-
* stale failure forever.
|
|
241
|
-
*/
|
|
242
|
-
export declare function getAppBinding(): Promise<AppBinding>;
|
package/dist/src/rpc.js
CHANGED
|
@@ -457,11 +457,11 @@ function readSessionCookie() {
|
|
|
457
457
|
return null;
|
|
458
458
|
}
|
|
459
459
|
/**
|
|
460
|
-
* Resolve the app's identity
|
|
460
|
+
* Resolve the app's identity from its own subdomain. Shared
|
|
461
461
|
* promise so the context bootstrap and the first data call coalesce into one
|
|
462
|
-
* `/by-subdomain` fetch. Does NOT touch the password gate —
|
|
463
|
-
*
|
|
464
|
-
*
|
|
462
|
+
* `/by-subdomain` fetch. Does NOT touch the password gate — the app's own
|
|
463
|
+
* identity is not the gated thing, so a password-gated app still resolves it
|
|
464
|
+
* before the prompt.
|
|
465
465
|
*/
|
|
466
466
|
function resolveAppInfo() {
|
|
467
467
|
if (appInfoPromise)
|
|
@@ -519,19 +519,30 @@ function gatewayErrorMessage(status) {
|
|
|
519
519
|
return "The service returned an unexpected response. Please try again.";
|
|
520
520
|
}
|
|
521
521
|
/**
|
|
522
|
-
* The error message for a non-ok response. A
|
|
523
|
-
* a `message
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* `
|
|
522
|
+
* The error message for a non-ok response. A JSON error the API AUTHORED surfaces
|
|
523
|
+
* verbatim — a 4xx carrying a `message`, or a 5xx that also carries `code`, the
|
|
524
|
+
* discriminator every error the API emits is built with. A non-JSON body (a
|
|
525
|
+
* gateway HTML page), a 5xx from something that is not us, or a body without a
|
|
526
|
+
* `message` falls back to a body-free, status-derived message — so a raw HTML
|
|
527
|
+
* body never becomes the message. `parsed` is the JSON.parse of the body, or
|
|
528
|
+
* `null` if it wasn't JSON.
|
|
527
529
|
*/
|
|
528
530
|
export function transportErrorMessage(status, parsed) {
|
|
529
531
|
const jsonMessage = parsed && typeof parsed.message === "string"
|
|
530
532
|
? parsed.message
|
|
531
533
|
: null;
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
534
|
+
if (jsonMessage === null)
|
|
535
|
+
return gatewayErrorMessage(status);
|
|
536
|
+
// Below 500 the body is ours. At 500 and above it is ours only if it carries
|
|
537
|
+
// `code` — the discriminator every error the API emits is built with. Status
|
|
538
|
+
// alone was the wrong test: it threw away the 503 our OWN backpressure gate
|
|
539
|
+
// authors and replaced it with a different English sentence, so no wording we
|
|
540
|
+
// choose for a 5xx could ever reach an app member (GAP-300). A gateway's HTML
|
|
541
|
+
// does not parse at all, and a proxy's JSON does not carry `code`, so both
|
|
542
|
+
// still get the body-free message.
|
|
543
|
+
const authored = typeof parsed.code === "string" &&
|
|
544
|
+
parsed.code.length > 0;
|
|
545
|
+
return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
|
|
535
546
|
}
|
|
536
547
|
/**
|
|
537
548
|
* The error a stream that never started should throw.
|
|
@@ -653,8 +664,6 @@ function rpcStandalone(op, payload) {
|
|
|
653
664
|
return standaloneMembers(payload);
|
|
654
665
|
case "context":
|
|
655
666
|
return standaloneContext();
|
|
656
|
-
case "binding":
|
|
657
|
-
return standaloneBinding();
|
|
658
667
|
case "openExternal":
|
|
659
668
|
return standaloneOpenExternal(payload);
|
|
660
669
|
case "askAi":
|
|
@@ -685,46 +694,6 @@ function rpcStandalone(op, payload) {
|
|
|
685
694
|
function rejectCommentsStandalone() {
|
|
686
695
|
return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
|
|
687
696
|
}
|
|
688
|
-
let bindingPromise;
|
|
689
|
-
/**
|
|
690
|
-
* Fetch the installation's binding, once per boot (module-cached — every
|
|
691
|
-
* `app_fields` import shares the same in-flight promise). Called at module
|
|
692
|
-
* load via top-level await, so it must work before `mount()` — and a failure
|
|
693
|
-
* there rejects the whole module graph (blank frame, no ErrorBoundary can
|
|
694
|
-
* catch module evaluation). Two mitigations, mirroring `boot()`:
|
|
695
|
-
* bounded retries absorb a transient transport blip, and a rejection is never
|
|
696
|
-
* cached so a remount/direct caller can retry rather than replaying the same
|
|
697
|
-
* stale failure forever.
|
|
698
|
-
*/
|
|
699
|
-
export function getAppBinding() {
|
|
700
|
-
if (bindingPromise === undefined) {
|
|
701
|
-
const attempt = fetchBindingWithRetry();
|
|
702
|
-
bindingPromise = attempt;
|
|
703
|
-
attempt.catch(() => {
|
|
704
|
-
if (bindingPromise === attempt)
|
|
705
|
-
bindingPromise = undefined;
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
return bindingPromise;
|
|
709
|
-
}
|
|
710
|
-
async function fetchBindingWithRetry() {
|
|
711
|
-
const delays = [500, 1500];
|
|
712
|
-
for (const delay of delays) {
|
|
713
|
-
try {
|
|
714
|
-
return await rpc("binding", {});
|
|
715
|
-
}
|
|
716
|
-
catch {
|
|
717
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
return rpc("binding", {});
|
|
721
|
-
}
|
|
722
|
-
async function standaloneBinding() {
|
|
723
|
-
const { app_id } = await boot();
|
|
724
|
-
return (await apiCall("GET", `/v1/apps/${app_id}/binding`, undefined, {
|
|
725
|
-
appId: app_id,
|
|
726
|
-
}));
|
|
727
|
-
}
|
|
728
697
|
async function standaloneMembers(p) {
|
|
729
698
|
const { app_id } = await boot();
|
|
730
699
|
const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
|
|
@@ -764,15 +733,10 @@ async function standaloneUrlStateSet(p) {
|
|
|
764
733
|
async function standaloneContext() {
|
|
765
734
|
const info = await resolveAppInfo();
|
|
766
735
|
return {
|
|
767
|
-
app_id: info.app_id,
|
|
768
|
-
app_name: info.app_name,
|
|
769
|
-
workspace_id: info.workspace_id,
|
|
770
|
-
organization_id: info.organization_id,
|
|
771
736
|
// No host session in standalone mode — the visitor is anonymous, so
|
|
772
737
|
// comments are unavailable regardless of the capability flag.
|
|
773
738
|
member_id: null,
|
|
774
739
|
comments_enabled: info.comments_enabled,
|
|
775
|
-
config: info.config ?? {},
|
|
776
740
|
};
|
|
777
741
|
}
|
|
778
742
|
async function standaloneQuery(p) {
|
|
@@ -3,9 +3,14 @@ export interface OptimisticApi<T> {
|
|
|
3
3
|
items: T[];
|
|
4
4
|
/**
|
|
5
5
|
* Optimistically merge `next` into the item keyed `id`, then run `persist`.
|
|
6
|
-
* On resolve → `onSettled?.()
|
|
7
|
-
*
|
|
8
|
-
* the patch is reverted.
|
|
6
|
+
* On resolve → `onSettled?.()`; the patch is kept (it should already match
|
|
7
|
+
* what the write persisted, so the re-read lands underneath it without a
|
|
8
|
+
* flicker). On reject → the patch is reverted.
|
|
9
|
+
*
|
|
10
|
+
* `onSettled` is NOT for refetching the query this patch came from — a
|
|
11
|
+
* successful `useWorkflow` inside `persist` re-reads every mounted query on
|
|
12
|
+
* its own. Use it for what the write cannot know about: a total the app
|
|
13
|
+
* computed itself, an indicator to clear.
|
|
9
14
|
*/
|
|
10
15
|
patch: (id: string, next: Partial<T>, persist: () => Promise<unknown>, opts?: {
|
|
11
16
|
onSettled?: () => void;
|
package/dist/src/viewer.d.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { type AppConfigValue } from "./rpc.js";
|
|
2
1
|
/**
|
|
3
2
|
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
4
3
|
* The host (the product iframe, or `lotics app dev`) supplies the signed-in
|
|
5
|
-
* member
|
|
4
|
+
* member and the app's declared capabilities.
|
|
6
5
|
*/
|
|
7
6
|
export declare function useAppContext(): {
|
|
8
7
|
memberId: string | null;
|
|
9
8
|
commentsEnabled: boolean;
|
|
10
|
-
config: Record<string, AppConfigValue>;
|
|
11
9
|
resolved: boolean;
|
|
12
10
|
};
|
|
13
11
|
/**
|
package/dist/src/viewer.js
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import useSWR from "swr";
|
|
2
2
|
import { rpc } from "./rpc.js";
|
|
3
|
-
// Stable identity for the not-yet-resolved / bespoke-app case. An inline `{}`
|
|
4
|
-
// fallback would be a fresh reference every render, defeating useConfig's
|
|
5
|
-
// memo (its result would recompute — and re-render consumers — on every
|
|
6
|
-
// render even with unchanged values).
|
|
7
|
-
const EMPTY_CONFIG = {};
|
|
8
3
|
/**
|
|
9
4
|
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
10
5
|
* The host (the product iframe, or `lotics app dev`) supplies the signed-in
|
|
11
|
-
* member
|
|
6
|
+
* member and the app's declared capabilities.
|
|
12
7
|
*/
|
|
13
8
|
export function useAppContext() {
|
|
14
9
|
const { data } = useSWR("app-context", () => rpc("context", {}), {
|
|
@@ -20,7 +15,6 @@ export function useAppContext() {
|
|
|
20
15
|
return {
|
|
21
16
|
memberId: data?.member_id ?? null,
|
|
22
17
|
commentsEnabled: data?.comments_enabled ?? false,
|
|
23
|
-
config: data?.config ?? EMPTY_CONFIG,
|
|
24
18
|
resolved: data !== undefined,
|
|
25
19
|
};
|
|
26
20
|
}
|
package/docs/ai.md
CHANGED
|
@@ -28,7 +28,7 @@ A declaration carries:
|
|
|
28
28
|
| `effort_level` | Optional reasoning depth for adaptive-thinking tiers. Requires an explicit `model_tier` pin — effort is tuned per tier |
|
|
29
29
|
| `prefix_cache_ttl` | Optional prompt-cache window for the agent's stable prefix (tools + system). **Omit it** — the default (`"5m"`, Anthropic's own) is right for essentially every agent. `"1h"` is a leveraged bet: it doubles the write price (2x the input rate against 1.25x, both reading back at 0.1x) to buy only the five-minute-to-one-hour band. Declining it is never "uncached" — the same prefix stays cached at the default window. Set `"1h"` only with measured cadence showing runs reliably land in that band, such as a scheduled sweep |
|
|
30
30
|
| `inputs` | Optional typed input schema for one run — the same vocabulary as workflow inputs (`text`, `number`, `file`, `member`, `record_link`, `select`, …). The server validates every run payload against it. **Every field defaults to `required: true`**, exactly as `outputs` does — the two extend the same base — so an input the caller may legitimately omit needs `"required": false`, or the run is rejected before the agent sees it |
|
|
31
|
-
| `outputs` | Optional typed output schema. Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose). **Every field defaults to `required: true`** (the same base schema as `inputs` above) — mark `"required": false` on anything the source may legitimately not carry. It matters most for `number`, which has no blank: text can answer `""`, but a required number leaves only a wrong value or a rejected submission |
|
|
31
|
+
| `outputs` | Optional typed output schema — the declaration vocabulary, including the two ways a `select` names its option set, is [mutations](./mutations.md#structured-results-return-data). Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose). **Every field defaults to `required: true`** (the same base schema as `inputs` above) — mark `"required": false` on anything the source may legitimately not carry. It matters most for `number`, which has no blank: text can answer `""`, but a required number leaves only a wrong value or a rejected submission |
|
|
32
32
|
|
|
33
33
|
**Structured vs free-text is the load-bearing split.** A structured agent's result arrives in `run.output` (the server strictly validates the submitted result against the declared schema — see the `output` typing section for the exact guarantee); a free-text agent's answer is the transcript's prose (`run.text`) and its `output` stays `undefined` — never a stray string, so a consumer reading `output.<field>` can't crash on a free-text answer.
|
|
34
34
|
|
|
@@ -145,7 +145,9 @@ The server validates the agent's submitted result strictly (unknown keys rejecte
|
|
|
145
145
|
|
|
146
146
|
- a field declared `required: false` may be absent;
|
|
147
147
|
- a `json`-typed output field is passthrough — anything goes inside it;
|
|
148
|
-
- `date` / `datetime` / `email`
|
|
148
|
+
- a `date` / `datetime` / `email` output is checked for well-formedness (`yyyy-MM-dd`, an ISO
|
|
149
|
+
timestamp, an address shape) but not for being the *right* value — a parseable date can still
|
|
150
|
+
be the wrong date;
|
|
149
151
|
- every **value** was authored by the model — a schema-valid string can still be semantically wrong;
|
|
150
152
|
- one live-stream corner: the hook adopts `output` from the submit call's **arguments as they stream**, before the server-side validation runs. Normally the loud rejection makes the agent retry and the last (valid) submit overwrites it — but a run whose *final* submit was rejected ends the live stream with that invalid attempt still in `output` (the persisted run settles as an error). The review surface below is the backstop.
|
|
151
153
|
|
package/docs/data_fetching.md
CHANGED
|
@@ -178,6 +178,15 @@ server validates system conditions by `type` and never reads `field_key` on them
|
|
|
178
178
|
| `query execution failed` | The query failed at the database. Deliberately generic — database internals are never sent to the client. | The app author diagnoses from the platform's server logs; the app surfaces the message. |
|
|
179
179
|
| A specific validation message | e.g. an un-projected `field_key` in runtime `sort`/`filter`, invalid params, an unknown alias. An unknown column names every column the query DOES project, so the valid set is in the message. | Fix the call site — these are contract violations, not transient. |
|
|
180
180
|
|
|
181
|
+
**Which message you get is decided by the ENVELOPE, not the status** (SDK 0.85.0 — before it, the
|
|
182
|
+
status alone decided). A failure whose JSON body carries a `code` is one the API authored, so its
|
|
183
|
+
`message` is what `error` holds — including a 5xx, which is why the shed sentence in the table above
|
|
184
|
+
reaches the app at all rather than being replaced by a generic one. Everything else — a gateway's
|
|
185
|
+
HTML page, a proxy's JSON with no `code`, a body with no `message` — is plumbing, and `error` holds
|
|
186
|
+
a fixed sentence derived from the status instead, so a raw HTML body can never become your error
|
|
187
|
+
copy. Practically: for a 5xx you are now showing a sentence the PLATFORM wrote, so treat `error` as
|
|
188
|
+
copy to display, never as a string to branch on.
|
|
189
|
+
|
|
181
190
|
## Pagination — two models
|
|
182
191
|
|
|
183
192
|
Reads paginate two different ways, and the difference is load-bearing:
|
package/docs/mutations.md
CHANGED
|
@@ -138,9 +138,23 @@ envelope's grammar — `message` is required, `field_errors` never reaches the a
|
|
|
138
138
|
refreshes that alias's generated types in the same command, so `result.data` is typed
|
|
139
139
|
immediately — no hand-copy of the echoed schema, no second `lotics app codegen`. An
|
|
140
140
|
**explicitly declared** `outputs` is authoritative and never overwritten. Declare an explicit
|
|
141
|
-
`outputs`
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
`outputs` only to narrow beyond what's inferred; a shape the checker can't pin down degrades
|
|
142
|
+
to untyped `json`, never to a wrong schema.
|
|
143
|
+
- **The output vocabulary is the input one minus the caller-only types, and `select` behaves
|
|
144
|
+
differently.** Scalars (`text`/`number`/`boolean`/`date`/`datetime`/`email`), `record_link`
|
|
145
|
+
(`table_id`, `multi?`), `select`, `json`, and nested `object` (`fields`) / `array` (`items`).
|
|
146
|
+
No `member`, `file`, or `date_range` — those describe what a caller *sends*. `select` names
|
|
147
|
+
its option set the same two ways as an input (**exactly one** of inline
|
|
148
|
+
`options: [{label, value}]` or `field: "fld_…"`, and a `field` that doesn't exist or names a
|
|
149
|
+
non-select field is rejected at bind time by the same gate, worded `select output references
|
|
150
|
+
field …`), but enforcement is **stricter than on the input side**: an output's option set is
|
|
151
|
+
enforced as *membership*, not merely key format. Inline `options` are enforced against the
|
|
152
|
+
frozen set; a `field` form is resolved to the field's **current** options at run and enforced
|
|
153
|
+
against those. So a producer — a workflow's `return({ data })`, an app agent's
|
|
154
|
+
`submit_result` — is handed the legal keys and cannot settle a label where a key belongs.
|
|
155
|
+
Prefer `field` for any select backed by a real field (the generated type tracks it, no
|
|
156
|
+
redeploy); use inline `options` for a fixed enum the app owns or a select you plan to
|
|
157
|
+
**package** (a `field` form carries a concrete field id and can't ride a package contract).
|
|
144
158
|
- **Validated at run.** On a success return, the returned `data` is validated against the
|
|
145
159
|
schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
|
|
146
160
|
message, so a declared output is a real contract. An *error* return's `data` passes through
|
|
@@ -347,7 +361,8 @@ flash to a spinner — `loading` stays false during revalidation). `usePaginated
|
|
|
347
361
|
yourself is yours to refresh, so a write that changes the row COUNT must also refresh whatever
|
|
348
362
|
you read it from, or the page moves while "of N" does not. Focus revalidation
|
|
349
363
|
(`revalidateOnFocus`, default on) eventually self-corrects stale data, but never rely on it
|
|
350
|
-
|
|
364
|
+
where the staleness is one of those three — the user is looking at the number now, not the
|
|
365
|
+
next time they come back to the tab.
|
|
351
366
|
|
|
352
367
|
### A read must not overtake an in-flight write
|
|
353
368
|
|
|
@@ -506,9 +521,10 @@ Contract points:
|
|
|
506
521
|
a whole. Include every changed field in the same call; don't submit per-field requests.
|
|
507
522
|
- **Nothing changes immediately.** The tool returns
|
|
508
523
|
`{ approval_request_id, status: "pending" }`; the record's values update only when an
|
|
509
|
-
approver (the table's configured approvers, else org admins) accepts.
|
|
510
|
-
|
|
511
|
-
|
|
524
|
+
approver (the table's configured approvers, else org admins) accepts. The submission
|
|
525
|
+
succeeds, so the automatic re-read fires and returns the *old* values — that is correct,
|
|
526
|
+
and it is why the pending state must come from the RESULT rather than from the row. Render
|
|
527
|
+
it from `data.request_id`; don't poll the record for values no one has approved yet.
|
|
512
528
|
- **Requires a member actor.** The request is attributed to the triggering member; an
|
|
513
529
|
anonymous caller through a public app is rejected. Gate the affordance on a signed-in
|
|
514
530
|
viewer.
|
|
@@ -523,7 +539,8 @@ const save = async () => {
|
|
|
523
539
|
if (res.status === "success") showPending();
|
|
524
540
|
} else {
|
|
525
541
|
const res = await updateOrder({ record_id, ...changes });
|
|
526
|
-
if (res.status === "
|
|
542
|
+
if (res.status === "error") { showError(res.message); return; }
|
|
543
|
+
// orders re-reads itself — a successful write already said so.
|
|
527
544
|
}
|
|
528
545
|
};
|
|
529
546
|
```
|
|
@@ -543,9 +560,18 @@ patch(id, next, persist, opts?);
|
|
|
543
560
|
item's stable key. `items` is `base` with pending patches merged per key (`{ ...item,
|
|
544
561
|
...patch }`); repeated patches on the same key merge.
|
|
545
562
|
- `patch(id, next, persist, { onSettled })` applies `next` immediately, then runs the
|
|
546
|
-
`persist` thunk. On **resolve**, the patch is *kept*
|
|
547
|
-
|
|
548
|
-
|
|
563
|
+
`persist` thunk. On **resolve**, the patch is *kept* and `onSettled` runs. On **reject**,
|
|
564
|
+
the patch is *reverted*.
|
|
565
|
+
- **`onSettled` is not where you refetch the query the patch came from.** The `persist` thunk
|
|
566
|
+
calls `useWorkflow`, and a successful write re-reads every mounted query by itself — so the
|
|
567
|
+
re-read is already in flight before `onSettled` fires, and passing `refetch` here buys a
|
|
568
|
+
second execution of the same query. Keep it for what the write cannot know about: a total
|
|
569
|
+
you computed yourself, a "saving…" indicator to clear, an analytics call.
|
|
570
|
+
- **A kept patch stays merged over `base` for the life of the view.** It is never cleared on
|
|
571
|
+
success — the design assumes it equals what the server stored, so the re-read lands
|
|
572
|
+
underneath it with no flicker. Patch the value you are SENDING, not a display form of it: if
|
|
573
|
+
the server normalizes (rounds a time, trims a string, resolves a link's label), the
|
|
574
|
+
optimistic value masks the stored one permanently and nothing reports it.
|
|
549
575
|
|
|
550
576
|
**Warning — the persist thunk must throw on `status: "error"`.** `useWorkflow` resolves on
|
|
551
577
|
failure (the failure model above), and a resolved promise means "kept" to `useOptimistic` —
|
|
@@ -555,6 +581,7 @@ workflow failed. Convert the status into a rejection:
|
|
|
555
581
|
```tsx
|
|
556
582
|
const q = useQuery("events");
|
|
557
583
|
const reschedule = useWorkflow("rescheduleEvent");
|
|
584
|
+
const mapped = useMemo(() => q.rows.map(toCalendarEvent), [q.rows]);
|
|
558
585
|
const { items, patch } = useOptimistic(mapped, (e) => e.id);
|
|
559
586
|
|
|
560
587
|
const onEventDrop = (ev: CalendarEvent, newStart: Date) =>
|
|
@@ -566,14 +593,13 @@ const onEventDrop = (ev: CalendarEvent, newStart: Date) =>
|
|
|
566
593
|
const r = await reschedule({ record_id: ev.recordId, new_date: toISODate(newStart) });
|
|
567
594
|
if (r.status === "error") throw new Error(r.message ?? "Reschedule failed");
|
|
568
595
|
},
|
|
569
|
-
{ onSettled: q.refetch },
|
|
570
596
|
);
|
|
571
597
|
```
|
|
572
598
|
|
|
573
599
|
This is the full read → mutate → reconcile loop: `useQuery` reads, `row.*` coerces,
|
|
574
|
-
`useWorkflow` mutates, `useOptimistic`
|
|
575
|
-
one workflow per (table, field) you mutate — typed and narrow, never a
|
|
576
|
-
`setField(any_field)` that hands the client write access to every field
|
|
600
|
+
`useWorkflow` mutates, `useOptimistic` covers the round-trip until the write's own re-read
|
|
601
|
+
converges. Declare one workflow per (table, field) you mutate — typed and narrow, never a
|
|
602
|
+
generic `setField(any_field)` that hands the client write access to every field
|
|
577
603
|
([security](./security.md)).
|
|
578
604
|
|
|
579
605
|
## Editing a record that does not exist yet: `useNewRecord`
|
|
@@ -590,7 +616,7 @@ id is known from the first render and the create stops being an event the UI has
|
|
|
590
616
|
const { id, save } = useNewRecord({
|
|
591
617
|
create: (id, patch) => createCustomer({ record_id: id, ...patch }),
|
|
592
618
|
update: (id, patch) => updateCustomer({ record_id: id, ...patch }),
|
|
593
|
-
onCreated:
|
|
619
|
+
onCreated: (id) => select(id),
|
|
594
620
|
});
|
|
595
621
|
|
|
596
622
|
<InlineText onBlur={(name) => save({ name })} />
|
|
@@ -605,6 +631,9 @@ const { id, save } = useNewRecord({
|
|
|
605
631
|
- **A failed create does not latch.** The next `save` retries the create, rather than updating a
|
|
606
632
|
row that was never written while the UI looks like it saved.
|
|
607
633
|
- `id` and `save` are stable across renders, so `save` can be bound directly to an `onBlur`.
|
|
634
|
+
- **`onCreated` is not for refetching the list.** The `create` workflow succeeded, so every
|
|
635
|
+
mounted query re-read itself already. It runs once, with the id, for what only the id can
|
|
636
|
+
drive — routing to the record, selecting it, dropping the surface's "new" state.
|
|
608
637
|
|
|
609
638
|
Your `create` workflow must pass the id through to `create_records` as `ids: [record_id]`.
|
|
610
639
|
Creation is creation: an id that already exists is a conflict, never an overwrite — so the
|
package/docs/runtime.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# The app runtime
|
|
2
2
|
|
|
3
3
|
How a custom-code app boots and talks to the platform: **`mount()`** (the entry
|
|
4
|
-
point, including the design-time mock harness
|
|
4
|
+
point, including the design-time mock harness),
|
|
5
5
|
the **two transports** the SDK switches between (embedded postMessage bridge vs.
|
|
6
6
|
standalone direct API — app code never branches), the raw **`rpc()`** escape
|
|
7
7
|
hatch, and the browser capabilities the sandbox would otherwise block —
|
|
@@ -32,9 +32,6 @@ mount(<App />);
|
|
|
32
32
|
visible in the iframe itself, even before any host telemetry is wired up.
|
|
33
33
|
Banners are informational only; they are not removed automatically.
|
|
34
34
|
4. **Renders the tree** with React 19's `createRoot`.
|
|
35
|
-
5. **Boots analytics** fire-and-forget (see [Automatic analytics](#automatic-analytics-posthog)).
|
|
36
|
-
Never awaited — it cannot delay first paint, and any failure leaves the app
|
|
37
|
-
fully functional and untracked.
|
|
38
35
|
|
|
39
36
|
### The mock harness (`options.fixture` + `?__mock=1`)
|
|
40
37
|
|
|
@@ -239,14 +236,11 @@ semantics are documented:
|
|
|
239
236
|
The **streaming** agent-run op is *not* reachable through `rpc()` — its
|
|
240
237
|
response is a chunk stream, not a single value; it's internal to `useAgentRun`.
|
|
241
238
|
|
|
242
|
-
`rpc("context", {})` resolves the
|
|
243
|
-
|
|
244
|
-
`
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
the context type is not exported from the package root — type the result
|
|
248
|
-
yourself via the `rpc<T>` generic (`AppConfigValue`, the config value type, *is*
|
|
249
|
-
exported).
|
|
239
|
+
`rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled }`.
|
|
240
|
+
`member_id` is the signed-in member when embedded, `null` standalone — read it
|
|
241
|
+
through `useViewer()` rather than this op. **Limitation:** the context type is
|
|
242
|
+
not exported from the package root, so type the result yourself via the `rpc<T>`
|
|
243
|
+
generic.
|
|
250
244
|
|
|
251
245
|
## `openExternal()` — open a link in a new tab
|
|
252
246
|
|
|
@@ -359,106 +353,21 @@ await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
|
|
|
359
353
|
there is no workflow log to find. If field failures need diagnosing, surface
|
|
360
354
|
the `reason` visibly in the UI.
|
|
361
355
|
|
|
362
|
-
##
|
|
356
|
+
## Analytics
|
|
363
357
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
358
|
+
The SDK ships no analytics and no hook for app-defined events. Don't add
|
|
359
|
+
`posthog-js` or another browser SDK to an app either: the app host serves every
|
|
360
|
+
response under a `Content-Security-Policy` whose `connect-src` reaches only the
|
|
361
|
+
Lotics API, so a third-party collector's requests are blocked and the only
|
|
362
|
+
visible result is console noise.
|
|
369
363
|
|
|
370
|
-
|
|
371
|
-
Everything an app *does* is already recorded by the platform — a comment, an
|
|
364
|
+
Most of what an app does is already recorded platform-side — a comment, an
|
|
372
365
|
upload, a workflow or agent run each leave a row, and a run's outcome, including
|
|
373
|
-
a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
init rather than relied on project-wide: the server-side setting fails open,
|
|
377
|
-
and an app screen renders the customer's own records.
|
|
378
|
-
- `app_opened` carries the app identity as super-properties — `app_id`,
|
|
379
|
-
`workspace_id`, `organization_id`, ids only, since a name is the customer's
|
|
380
|
-
own words — and rolls up under the `organization` group. Embedded apps `identify` the signed-in member — app and
|
|
381
|
-
product events share one person; standalone visitors stay anonymous.
|
|
382
|
-
- Uncaught exceptions are captured (PostHog error tracking) in addition to
|
|
383
|
-
`mount()`'s visible banner — the app's one error channel.
|
|
384
|
-
- **Tracking is gated to the deployed app host** (`*.lotics.app`). `lotics app
|
|
385
|
-
dev` (localhost) and any `?__mock=1` load emit nothing. Best-effort
|
|
386
|
-
throughout: a failed init or a failed `context` resolution leaves the app
|
|
387
|
-
fully working and untracked.
|
|
388
|
-
- **Limitation:** PostHog's default bot/user-agent filter applies — headless
|
|
389
|
-
browsers (e.g. Playwright) are never tracked, so analytics cannot be verified
|
|
390
|
-
through headless automation.
|
|
391
|
-
|
|
392
|
-
## Installation config — `useConfig()`
|
|
393
|
-
|
|
394
|
-
**`useConfig(defaults?)`** → `{ config, loading }` — the App-Packages customization
|
|
395
|
-
knobs for this installation (labels, toggles, theme, column choices), keyed by the
|
|
396
|
-
alias the package contract declares. Exact signature: `dist/src/config.d.ts`.
|
|
366
|
+
a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`. App opens are
|
|
367
|
+
counted at the serving edge. If a funnel needs something those cannot answer,
|
|
368
|
+
request it as a platform change.
|
|
397
369
|
|
|
398
|
-
|
|
399
|
-
const { config } = useConfig({ board_title: "Tasks", show_archive: false });
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
- **Pass the contract's defaults.** They fill un-overridden knobs and paint
|
|
403
|
-
flicker-free before the context resolves; the installation's stored values
|
|
404
|
-
overlay them (a stored value always wins over the same-key default). `loading`
|
|
405
|
-
is true only until the **one-shot** context RPC settles (it never revalidates)
|
|
406
|
-
— gate on it only for config-derived layout that must not flash a default.
|
|
407
|
-
- Values are `string | number | boolean` (`AppConfigValue`, exported from the
|
|
408
|
-
package root). The returned object is referentially stable across renders while
|
|
409
|
-
the values are unchanged — the merge is memoized, and the defaults are keyed by
|
|
410
|
-
their *content*, so passing a fresh inline `{…}` literal every render is fine
|
|
411
|
-
(no churn); it's safe as a `useEffect`/`useMemo` dependency.
|
|
412
|
-
- **Bespoke apps** (not installed from a package) get `{}` from the host — the
|
|
413
|
-
hook resolves to just the defaults, so shared code needs no package check.
|
|
414
|
-
- **A standalone (public) app behind a password never receives its stored config
|
|
415
|
-
in the client.** The knobs are gated server-side (they can carry business
|
|
416
|
-
terms), and the SDK resolves the app's identity once — before the visitor
|
|
417
|
-
authenticates — so `useConfig` returns the defaults only, even after unlock.
|
|
418
|
-
Design a public, password-gated app to be correct on its defaults alone.
|
|
419
|
-
(Embedded and standalone-*unprotected* apps both get the stored config.)
|
|
420
|
-
- **Read-only.** Config is edited in the product (the installation's settings),
|
|
421
|
-
never written from app code — there is no setter.
|
|
422
|
-
|
|
423
|
-
## Installation binding — `getAppBinding()` (package apps)
|
|
424
|
-
|
|
425
|
-
**`getAppBinding()`** → `Promise<AppBinding>` — a package installation's
|
|
426
|
-
alias→concrete-id maps: `{ fields, options, roles }`, keyed by fully-qualified
|
|
427
|
-
contract aliases (`"tasks.title"`, `"tasks.status:to_do"`, role alias) with this
|
|
428
|
-
workspace's `fld_`/`opt_`/`grp_` ids as values. Exact signature:
|
|
429
|
-
`dist/src/rpc.d.ts`.
|
|
430
|
-
|
|
431
|
-
You normally never call it yourself: on a package installation (a published
|
|
432
|
-
origin, or an install of someone's package) `lotics app codegen` emits a
|
|
433
|
-
binding-form `.lotics/app_fields.ts` that calls it once at **module load** via
|
|
434
|
-
top-level await and exports plain-string `F` / `OPT` / `ROLE` maps — the same
|
|
435
|
-
import surface as a bespoke app's baked codegen, but resolved per-installation
|
|
436
|
-
at runtime:
|
|
437
|
-
|
|
438
|
-
```ts
|
|
439
|
-
import { OPT } from "../.lotics/app_fields";
|
|
440
|
-
const STATUS_DONE = OPT.TASKS.status.done; // this installation's "opt_…"
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
- **One fetch per boot.** The promise is module-cached; every importer shares
|
|
444
|
-
it. The ESM graph awaits it before any dependent module evaluates, so the
|
|
445
|
-
values are ordinary strings everywhere — including module-top-level constants.
|
|
446
|
-
Transient failures are retried (3 attempts, short backoff) and a rejection is
|
|
447
|
-
never cached — a module-load failure rejects the whole graph (blank frame),
|
|
448
|
-
so the fetch absorbs blips rather than bricking the boot on one lost request.
|
|
449
|
-
- **Requires the starter's `build.target: "es2022"`** (top-level await does not
|
|
450
|
-
exist below it). An app scaffolded before the starter set that target must
|
|
451
|
-
set it in `vite.config.ts` before shipping the binding form.
|
|
452
|
-
- **Fails loud.** A key missing from the binding throws at boot with the alias
|
|
453
|
-
named — the binding is verified complete at install/adopt, so a miss means
|
|
454
|
-
the generated file is stale relative to the installed contract version
|
|
455
|
-
(re-run the codegen, republish).
|
|
456
|
-
- **Bespoke apps 404.** An app that is not a package installation has no
|
|
457
|
-
binding; codegen emits the baked variant for it instead. Shared code should
|
|
458
|
-
not call this directly — import from `.lotics/app_fields` and let codegen
|
|
459
|
-
pick the form.
|
|
460
|
-
|
|
461
|
-
## For package contributors
|
|
370
|
+
## For SDK contributors
|
|
462
371
|
|
|
463
372
|
Everything below concerns changing `@lotics/app-sdk` itself (in the Lotics
|
|
464
373
|
monorepo), not building apps with it.
|
|
@@ -518,7 +427,7 @@ is a transport that wasn't wired.
|
|
|
518
427
|
- **Data + RPC only — zero UI.** Never re-export a `@lotics/ui` component; the
|
|
519
428
|
SDK stays off the React-Native-Web dependency tree. Apps import `@lotics/ui`
|
|
520
429
|
directly.
|
|
521
|
-
- **Keep the dependency set minimal.** Runtime deps are `
|
|
430
|
+
- **Keep the dependency set minimal.** Runtime deps are `ai` and `swr`;
|
|
522
431
|
`react`/`react-dom` are peers. `react-router` is an **optional** peer
|
|
523
432
|
pulled in only by the `@lotics/app-sdk/router` subpath export — the root entry
|
|
524
433
|
must never import it. (`react-router` is the canonical package; the
|
package/docs/workflows.md
CHANGED
|
@@ -425,6 +425,15 @@ full menu by category, so a miss is one informed retry.
|
|
|
425
425
|
| **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
|
|
426
426
|
| **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])` |
|
|
427
427
|
|
|
428
|
+
**Emptiness on a record cell.** `isNull` is the strict check — true only for `null`/`undefined`.
|
|
429
|
+
A cleared cell does not always reach a workflow that way: the platform stores a cleared date or
|
|
430
|
+
text as `""` and a cleared select, link or files cell as `[]`, so `isNull(record.data.ngay_doi_soat)`
|
|
431
|
+
is **false** on a date the user emptied. Reach for `isEmpty` for the "has the user filled this in?"
|
|
432
|
+
question — it covers `null`, `undefined`, `""` and `[]`. (`isNull` stays narrow on purpose: it is
|
|
433
|
+
declared as a type predicate and every `?.` in a body lowers to it, so widening it would narrow
|
|
434
|
+
`""` out of a branch that still receives it.) A FORMULA field is the one surface where the two
|
|
435
|
+
agree: its evaluation context normalizes every unset cell to `null` before the expression runs.
|
|
436
|
+
|
|
428
437
|
A few signatures worth knowing: `requireFirst(arr, message?)` asserts non-empty and returns `T`
|
|
429
438
|
rather than `T | undefined` — pair it with a `validate` on `size(x) == 0` instead of wrapping
|
|
430
439
|
every read in `if (x)`. `at(arr, -1)` counts from the end. `range(end)` / `range(start, end)`.
|
|
@@ -707,7 +716,8 @@ rejects, or read a path that is null on real data. The rehearsal for that is `dr
|
|
|
707
716
|
`app_workflow_inputs`. It walks the real step tree with the production expression evaluator and
|
|
708
717
|
hands back `planned_calls` (every tool call in order, with its fully-resolved input),
|
|
709
718
|
`return_value`, `validation_failures`, `evaluation_errors`, and `tool_input_errors` — inputs the
|
|
710
|
-
target tool would reject, which can only surface once the values are computed.
|
|
719
|
+
target tool would reject, which can only surface once the values are computed. Loops run for real,
|
|
720
|
+
every iteration, so a fold or a per-row fan-out is rehearsed at its true size. **Write tools are
|
|
711
721
|
recorded, never dispatched**, and nothing is persisted.
|
|
712
722
|
|
|
713
723
|
**Add `live_reads: true` whenever the body READS.** By default the read-only tools
|
|
@@ -721,6 +731,22 @@ which supply the real record as the payload AND turn live reads on; an app workf
|
|
|
721
731
|
record — its payload is its inputs — so it asks for the reads directly.) Do this before the first
|
|
722
732
|
live run of anything that writes.
|
|
723
733
|
|
|
734
|
+
**`return_value` is what the CALLER receives**, not a summary of it: `{ status, message }` always,
|
|
735
|
+
plus `data` when the body returns one and `field_errors` when it returns those — the same map a
|
|
736
|
+
form reads. A `validate` step that fails produces the same shape the live run does: every failing
|
|
737
|
+
check's message joined with `"; "`, and one `field_errors` entry per failing check that names a
|
|
738
|
+
`field_key`.
|
|
739
|
+
|
|
740
|
+
**A rehearsal can stop early, and it says so in `evaluation_errors`.** Four bounds apply — 1 000
|
|
741
|
+
iterations of a `while` / `do_while` / `c_for`, 10 000 steps, 50 live reads, and 20 seconds — and
|
|
742
|
+
tripping any of them appends an entry naming the bound and ending "the plan below is incomplete",
|
|
743
|
+
then stops. So read `evaluation_errors` before reading `planned_calls`: an entry there may be a
|
|
744
|
+
truncated plan rather than a bug in your body. A `foreach` is deliberately NOT capped at 1 000 —
|
|
745
|
+
its length is known before the first iteration, so the rehearsal walks every item and instead
|
|
746
|
+
refuses, up front, exactly the lists a live run refuses (over 10 000 items). The 50-live-read bound
|
|
747
|
+
is the one `live_reads: true` makes reachable: a loop body that reads issues one real query per
|
|
748
|
+
iteration.
|
|
749
|
+
|
|
724
750
|
## A worked body
|
|
725
751
|
|
|
726
752
|
An app action that creates an order after checking for a duplicate, then returns the new id.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.0",
|
|
4
4
|
"description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"ai": "^7.0.30",
|
|
30
|
-
"posthog-js": "^1.352.0",
|
|
31
30
|
"swr": "^2.4.1"
|
|
32
31
|
},
|
|
33
32
|
"peerDependencies": {
|
package/dist/src/analytics.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function bootstrapAnalytics(): Promise<void>;
|
package/dist/src/analytics.js
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PostHog analytics for custom-code apps.
|
|
3
|
-
*
|
|
4
|
-
* Apps are a separate cross-origin bundle at `<slug>.lotics.app`, invisible to
|
|
5
|
-
* the product's PostHog — so `mount()` boots a PostHog instance per app.
|
|
6
|
-
*
|
|
7
|
-
* **Why explicit events, not autocapture:** DOM autocapture ships `$el_text` —
|
|
8
|
-
* the visible text of whatever was clicked, i.e. the customer's own record
|
|
9
|
-
* values — and Điều 3.2 of the service contract warrants that our telemetry
|
|
10
|
-
* carries no record or document content. The flags below are what enforce
|
|
11
|
-
* that; the project-level setting is NOT a backstop, since it is server-side
|
|
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`.
|
|
26
|
-
*
|
|
27
|
-
* Every event is tagged with app identity and rolls up under the existing
|
|
28
|
-
* `organization` group; embedded apps `identify` the member the host passes
|
|
29
|
-
* down so app + product events share one person. Exception capture is on (the
|
|
30
|
-
* project opts in); session replay is off (on by default project-wide, but
|
|
31
|
-
* apps — including anonymous public visitors — were never scoped for it).
|
|
32
|
-
*
|
|
33
|
-
* The key is the public, write-only project key (already shipped in every
|
|
34
|
-
* browser), hardcoded here. Tracking is gated to the deployed app host
|
|
35
|
-
* (`*.lotics.app`); `lotics app dev` runs on localhost and stays untracked.
|
|
36
|
-
* Best-effort: any failure leaves the app fully functional and untracked.
|
|
37
|
-
*/
|
|
38
|
-
import posthog from "posthog-js";
|
|
39
|
-
import { rpc } from "./rpc.js";
|
|
40
|
-
import { hasMockFlag } from "./mock.js";
|
|
41
|
-
const POSTHOG_KEY = "phc_N1nyqSRdo9XMK3DODxrxX2Y9jG3dppybruOuMznbz62";
|
|
42
|
-
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
43
|
-
const APP_HOST_SUFFIX = ".lotics.app";
|
|
44
|
-
function onDeployedAppHost() {
|
|
45
|
-
try {
|
|
46
|
-
return window.location.hostname.endsWith(APP_HOST_SUFFIX);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
export async function bootstrapAnalytics() {
|
|
53
|
-
// Only the deployed app host tracks: a design-time/screenshot load
|
|
54
|
-
// (?__mock=1) and `lotics app dev` (localhost) emit nothing. This replaces
|
|
55
|
-
// the old "no key off-prod" gate now that the key is hardcoded.
|
|
56
|
-
if (hasMockFlag() || !onDeployedAppHost())
|
|
57
|
-
return;
|
|
58
|
-
let ctx;
|
|
59
|
-
try {
|
|
60
|
-
ctx = await rpc("context", {});
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// Best-effort: a context-resolution failure must never break the app.
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
posthog.init(POSTHOG_KEY, {
|
|
67
|
-
// Pin the modern posthog-js init contract (the product uses the same).
|
|
68
|
-
defaults: "2025-05-24",
|
|
69
|
-
api_host: POSTHOG_HOST,
|
|
70
|
-
// App analytics is explicit events — not DOM autocapture or pageviews.
|
|
71
|
-
// Set here rather than relied on project-wide: the server-side setting
|
|
72
|
-
// fails open, and an app screen renders the customer's own records.
|
|
73
|
-
autocapture: false,
|
|
74
|
-
mask_all_text: true,
|
|
75
|
-
capture_pageview: false,
|
|
76
|
-
capture_pageleave: false,
|
|
77
|
-
// Dead-click autocapture (enabled by the defaults preset) is a UX-research
|
|
78
|
-
// signal nobody consumes; explicit user actions + exception capture cover
|
|
79
|
-
// the app's monitoring needs.
|
|
80
|
-
capture_dead_clicks: false,
|
|
81
|
-
// Error tracking (the project opts in). mount() only renders a local
|
|
82
|
-
// banner, so this is the app's one exception channel.
|
|
83
|
-
capture_exceptions: true,
|
|
84
|
-
// Session replay is on by default project-wide; never on the app surface.
|
|
85
|
-
disable_session_recording: true,
|
|
86
|
-
// posthog-js keeps its default bot/user-agent filter, so headless/bot
|
|
87
|
-
// traffic is never tracked (real users are unaffected). One consequence:
|
|
88
|
-
// analytics can't be verified through headless Playwright — it's filtered.
|
|
89
|
-
//
|
|
90
|
-
// Tag + capture from `loaded` (once PostHog has initialized) — the robust
|
|
91
|
-
// point to register super-properties and emit the first event.
|
|
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.
|
|
97
|
-
ph.register({
|
|
98
|
-
app_id: ctx.app_id,
|
|
99
|
-
workspace_id: ctx.workspace_id,
|
|
100
|
-
organization_id: ctx.organization_id,
|
|
101
|
-
});
|
|
102
|
-
// Guarded: a bridged host that answered `context` before its auth member
|
|
103
|
-
// loaded sends an empty org — never group on an empty key.
|
|
104
|
-
if (ctx.organization_id)
|
|
105
|
-
ph.group("organization", ctx.organization_id);
|
|
106
|
-
// Embedded apps attach to the same person as the product; standalone
|
|
107
|
-
// visitors stay anonymous (member_id null).
|
|
108
|
-
if (ctx.member_id)
|
|
109
|
-
ph.identify(ctx.member_id);
|
|
110
|
-
ph.capture("app_opened");
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
}
|
package/dist/src/config.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import type { AppConfigValue } from "./rpc.js";
|
|
2
|
-
/**
|
|
3
|
-
* Read the installation's customization config — the first rung of the App
|
|
4
|
-
* Packages customization ladder (see docs/packages.md § The customization
|
|
5
|
-
* ladder). A package declares typed config knobs with defaults (labels, feature
|
|
6
|
-
* toggles, theme, column choices); each installation stores the customized values
|
|
7
|
-
* and the app reads them here. Updates flow: editing config in the product
|
|
8
|
-
* changes what this returns, with no re-deploy.
|
|
9
|
-
*
|
|
10
|
-
* `defaults` is the package contract's declared defaults — the canonical fallback
|
|
11
|
-
* (the codegen surface emits them so the call is fully typed). They fill any knob
|
|
12
|
-
* the installation hasn't overridden and provide a flicker-free first paint:
|
|
13
|
-
* before the context resolves the stored map is empty, so the defaults show
|
|
14
|
-
* immediately and the stored values overlay them once resolved.
|
|
15
|
-
*
|
|
16
|
-
* const { config } = useConfig({ "deal.label": "Deal", "show_archived": false });
|
|
17
|
-
* <Text>{config["deal.label"]}</Text>
|
|
18
|
-
*
|
|
19
|
-
* Gate on `loading` only for config-derived layout that must not flash a default.
|
|
20
|
-
*/
|
|
21
|
-
export declare function useConfig<T extends Record<string, AppConfigValue> = Record<string, AppConfigValue>>(defaults?: T): {
|
|
22
|
-
config: T;
|
|
23
|
-
loading: boolean;
|
|
24
|
-
};
|
package/dist/src/config.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import { useAppContext } from "./viewer.js";
|
|
3
|
-
/**
|
|
4
|
-
* Read the installation's customization config — the first rung of the App
|
|
5
|
-
* Packages customization ladder (see docs/packages.md § The customization
|
|
6
|
-
* ladder). A package declares typed config knobs with defaults (labels, feature
|
|
7
|
-
* toggles, theme, column choices); each installation stores the customized values
|
|
8
|
-
* and the app reads them here. Updates flow: editing config in the product
|
|
9
|
-
* changes what this returns, with no re-deploy.
|
|
10
|
-
*
|
|
11
|
-
* `defaults` is the package contract's declared defaults — the canonical fallback
|
|
12
|
-
* (the codegen surface emits them so the call is fully typed). They fill any knob
|
|
13
|
-
* the installation hasn't overridden and provide a flicker-free first paint:
|
|
14
|
-
* before the context resolves the stored map is empty, so the defaults show
|
|
15
|
-
* immediately and the stored values overlay them once resolved.
|
|
16
|
-
*
|
|
17
|
-
* const { config } = useConfig({ "deal.label": "Deal", "show_archived": false });
|
|
18
|
-
* <Text>{config["deal.label"]}</Text>
|
|
19
|
-
*
|
|
20
|
-
* Gate on `loading` only for config-derived layout that must not flash a default.
|
|
21
|
-
*/
|
|
22
|
-
export function useConfig(defaults) {
|
|
23
|
-
const ctx = useAppContext();
|
|
24
|
-
const stored = ctx.config;
|
|
25
|
-
// Key the memo on the defaults CONTENT, not identity. An inline literal is a
|
|
26
|
-
// fresh reference each render, so a content hash keeps the result reference
|
|
27
|
-
// stable while values are unchanged AND recomputes when a default value changes
|
|
28
|
-
// (e.g. an i18n label after a locale switch) — which a ref-captured defaults
|
|
29
|
-
// would silently miss. Config maps are tiny, so the stringify cost is trivial.
|
|
30
|
-
const defaultsKey = JSON.stringify(defaults ?? {});
|
|
31
|
-
const config = useMemo(() => ({ ...(defaults ?? {}), ...stored }), [stored, defaultsKey]);
|
|
32
|
-
return { config, loading: !ctx.resolved };
|
|
33
|
-
}
|