@lotics/app-sdk 0.102.2 → 0.103.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 CHANGED
@@ -17,7 +17,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
17
17
  |---|---|
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
19
19
  | [docs/queries.md](./docs/queries.md) | **The query engine reference** — AST node kinds, per-field-type operators, filters/params/pruning, free-text search, combining tables, shaping (aggregates, date buckets, windows), runtime refinement bounds, limits and the efficiency playbook. |
20
- | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path; `useWorkflows` for writes that are data), the `WorkflowResult` resolve-never-throw contract (`field_errors`), typed inputs, every write drawn on every read from the press — predicted from the workflow's own steps, taken back on a refusal, `pending` meanwhile — the re-read a successful write triggers over the tables its body names, diff-before-update, locked records, `useNewRecord`, read-after-write ordering (`writesSettled()`), `useRecording`. |
20
+ | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path; `useWorkflows` for writes that are data), the `WorkflowResult` resolve-never-throw contract (`field_errors`), typed inputs, every write drawn on every read from the press — predicted from the workflow's own steps, taken back on a refusal, `pending` meanwhile — the re-read a successful write triggers over the tables its body names, diff-before-update, locked records, `useNewRecord`, read-after-write ordering (`writesSettled()`), `useRecording`, `useRecordings` over several aliases. |
21
21
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY reference** — the JS subset a body may use, opaque `fld_*`/`opt_*` keys, every step form, the accepted sugar, helpers, record-write surfaces, the traps and the verify loop. |
22
22
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
23
23
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload` and its `fidelity`, `renameFile` (a new file over the same bytes), `useAttachments`/`useAttachmentPiles`, `readFiles` and presigned URLs (a bearer credential — never logged or persisted), workflow-generated files, naming a zip's entries, the delivery bounds. |
@@ -444,9 +444,14 @@ async function runUploadPipeline(file, rpc2, options = {}) {
444
444
  var IDLE_RECORDING = { phase: "idle" };
445
445
  var states = /* @__PURE__ */ new Map();
446
446
  var listeners = /* @__PURE__ */ new Set();
447
+ var version = 0;
447
448
  function emit() {
449
+ version += 1;
448
450
  for (const listener of listeners) listener();
449
451
  }
452
+ function recordingsVersion() {
453
+ return version;
454
+ }
450
455
  function subscribeRecordings(listener) {
451
456
  listeners.add(listener);
452
457
  return () => {
@@ -1115,6 +1120,7 @@ async function standaloneUpload(file, fidelity) {
1115
1120
  export {
1116
1121
  __export,
1117
1122
  DEFAULT_IMAGE_FIDELITY,
1123
+ recordingsVersion,
1118
1124
  subscribeRecordings,
1119
1125
  recordingStateOf,
1120
1126
  anyRecordingLive,
package/dist/index.d.ts CHANGED
@@ -11,8 +11,8 @@ export type { AttachedFile, AttachmentPiles, AttachmentPilesOptions, Attachments
11
11
  export { useComments, useCommentCounts } from "./comments.js";
12
12
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
13
13
  export { useAppContext, useViewer, useWorkspaceCurrency, useWorkspaceTimezone } from "./viewer.js";
14
- export { useRecording } from "./recording.js";
15
- export type { UseRecording } from "./recording.js";
14
+ export { useRecording, useRecordings } from "./recording.js";
15
+ export type { UseRecording, UseRecordings } from "./recording.js";
16
16
  export type { RecordingState, RecordingInputs } from "./recording_state.js";
17
17
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
18
18
  export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  postHostNotification,
13
13
  readRecordingSnapshot,
14
14
  recordingStateOf,
15
+ recordingsVersion,
15
16
  replaceRecordings,
16
17
  rpc,
17
18
  rpcAgentRun,
@@ -21,7 +22,7 @@ import {
21
22
  subscribeRecordings,
22
23
  subscribeUrlParams,
23
24
  urlParam
24
- } from "./chunk-ZOFKWF6V.js";
25
+ } from "./chunk-CRJQYSDI.js";
25
26
 
26
27
  // src/mount.tsx
27
28
  import { createRoot } from "react-dom/client";
@@ -23826,6 +23827,9 @@ var queryAggregateColumnSchema = zod_default.object({
23826
23827
  input_column: zod_default.string().optional().describe(
23827
23828
  "Input column to aggregate. Required for non-count operations; ignored for count."
23828
23829
  ),
23830
+ filter: tableRecordFiltersSchema.optional().describe(
23831
+ "Aggregates only the input rows that match (SQL `FILTER`). Conditions name input columns and take `{{params.x}}`; aggregates with different filters in one node read the input once."
23832
+ ),
23829
23833
  separator: zod_default.string().max(8).optional().describe('string_agg only. Joins the values. Defaults to ", ".'),
23830
23834
  distinct: zod_default.boolean().optional().describe(
23831
23835
  "string_agg only. Collapses repeats \u2014 a group of containers sized 40HC/40HC/20DC aggregates to '20DC, 40HC'. Defaults to true, which is almost always what a summary column wants."
@@ -24231,6 +24235,11 @@ var appQueryDeclarationSchema = zod_default.object({
24231
24235
  "Document templates an export of this query's rows may fill, by the name the export asks for. An export names one of these or none; a template it does not declare is refused."
24232
24236
  )
24233
24237
  });
24238
+ var appAgentWritesSchema = zod_default.object({
24239
+ table_id: zod_default.string().min(1).describe("The table of the row written"),
24240
+ row: zod_default.string().min(1).describe("The input naming that row: a single `record_link` input to `table_id`"),
24241
+ fields: zod_default.record(zod_default.string().min(1), zod_default.string().min(1)).refine((fields) => Object.keys(fields).length > 0, { message: "writes.fields maps at least one output" }).describe("Output name \u2192 the field key of `table_id` it is written to")
24242
+ }).strict();
24234
24243
  var appAgentDeclarationSchema = zod_default.object({
24235
24244
  instructions: zod_default.string().min(1).describe("System instructions for the agent \u2014 the task it performs per run."),
24236
24245
  tool_names: zod_default.array(zod_default.string().min(1)).describe(
@@ -24262,6 +24271,9 @@ var appAgentDeclarationSchema = zod_default.object({
24262
24271
  ).refine(
24263
24272
  (outputs) => outputs === void 0 || Object.values(outputs).every((o) => appWorkflowOutputDepth(o) <= MAX_APP_WORKFLOW_OUTPUT_DEPTH),
24264
24273
  { message: `output schema nesting exceeds the max depth of ${MAX_APP_WORKFLOW_OUTPUT_DEPTH}` }
24274
+ ),
24275
+ writes: appAgentWritesSchema.optional().describe(
24276
+ "Where the run's outputs land: each named output written to a field of the one row an input names, under the app's authority, once the result validates \u2014 locks and hooks apply as to any update. Omit for an agent whose output is only returned."
24265
24277
  )
24266
24278
  });
24267
24279
  var appThemeSchema = zod_default.object({
@@ -24393,6 +24405,12 @@ var agentStepSchema = stepBaseSchema.extend({
24393
24405
  model_tier: zod_default.enum(MODEL_TIERS).optional(),
24394
24406
  output: agentOutputSpecSchema
24395
24407
  });
24408
+ var appAgentStepSchema = stepBaseSchema.extend({
24409
+ type: zod_default.literal("app_agent"),
24410
+ alias: zod_default.string().min(1),
24411
+ input: zod_default.record(zod_default.string(), toolInputValueSchema),
24412
+ wait: zod_default.literal(false).optional()
24413
+ });
24396
24414
  var breakStepSchema = stepBaseSchema.extend({
24397
24415
  type: zod_default.literal("break")
24398
24416
  });
@@ -24419,6 +24437,7 @@ var workflowStepSchema = zod_default.lazy(
24419
24437
  returnStepSchema,
24420
24438
  validateStepSchema,
24421
24439
  agentStepSchema,
24440
+ appAgentStepSchema,
24422
24441
  breakStepSchema,
24423
24442
  continueStepSchema,
24424
24443
  letDeclareStepSchema,
@@ -25041,6 +25060,7 @@ function stepChildStepArrays(step) {
25041
25060
  case "bind":
25042
25061
  case "validate":
25043
25062
  case "agent":
25063
+ case "app_agent":
25044
25064
  case "break":
25045
25065
  case "continue":
25046
25066
  case "let_declare":
@@ -25073,6 +25093,7 @@ function changesRows(toolName) {
25073
25093
  }
25074
25094
  function rowWriteOf(step) {
25075
25095
  if (step.type === "agent") return step.tool_names.some(changesRows) ? { table_id: null } : void 0;
25096
+ if (step.type === "app_agent") return { table_id: null };
25076
25097
  if (step.type !== "tool_call") return void 0;
25077
25098
  const rows = toolRows(step.tool_name);
25078
25099
  if (rows === null) return void 0;
@@ -27201,7 +27222,8 @@ async function run(walk, step) {
27201
27222
  if (write2 !== void 0) note(walk, { kind: "unknown", table_id: write2.table_id });
27202
27223
  return unknownAt(walk.ctx.step_outputs, step.id);
27203
27224
  }
27204
- case "agent": {
27225
+ case "agent":
27226
+ case "app_agent": {
27205
27227
  const write2 = rowWriteOf(step);
27206
27228
  if (write2 !== void 0) note(walk, { kind: "unknown", table_id: write2.table_id });
27207
27229
  return unknownAt(walk.ctx.step_outputs, step.id);
@@ -31083,33 +31105,54 @@ function useCommentCounts(args) {
31083
31105
  }
31084
31106
 
31085
31107
  // src/recording.ts
31086
- import { useCallback as useCallback7, useSyncExternalStore as useSyncExternalStore3 } from "react";
31108
+ import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
31087
31109
  var NO_MOCKS = {};
31110
+ async function startRecording(alias, inputs) {
31111
+ if (getMockRecordings()?.[alias]) return { started: true };
31112
+ return rpc("recording.start", { alias, inputs });
31113
+ }
31114
+ async function stopRecording(alias) {
31115
+ if (getMockRecordings()?.[alias]) return;
31116
+ await rpc("recording.stop", { alias });
31117
+ }
31118
+ function busyWith(mocks, hostBusy) {
31119
+ return hostBusy || Object.values(mocks).some((state) => state.phase === "live");
31120
+ }
31088
31121
  function useRecording(alias) {
31089
31122
  const { recordingEnabled } = useAppContext();
31090
31123
  const mocks = getMockRecordings() ?? NO_MOCKS;
31091
31124
  const mocked = mocks[alias];
31092
31125
  const hostState = useSyncExternalStore3(subscribeRecordings, () => recordingStateOf(alias));
31093
31126
  const hostBusy = useSyncExternalStore3(subscribeRecordings, anyRecordingLive);
31094
- const start = useCallback7(
31095
- async (inputs) => {
31096
- if (getMockRecordings()?.[alias]) return { started: true };
31097
- return rpc("recording.start", { alias, inputs: inputs ?? {} });
31098
- },
31099
- [alias]
31100
- );
31101
- const stop = useCallback7(async () => {
31102
- if (getMockRecordings()?.[alias]) return;
31103
- await rpc("recording.stop", { alias });
31104
- }, [alias]);
31127
+ const start = useCallback7((inputs) => startRecording(alias, inputs ?? {}), [alias]);
31128
+ const stop = useCallback7(() => stopRecording(alias), [alias]);
31105
31129
  return {
31106
31130
  available: mocked !== void 0 || recordingEnabled,
31107
31131
  state: mocked ?? hostState,
31108
- busy: hostBusy || Object.values(mocks).some((state) => state.phase === "live"),
31132
+ busy: busyWith(mocks, hostBusy),
31109
31133
  start,
31110
31134
  stop
31111
31135
  };
31112
31136
  }
31137
+ function useRecordings(aliases) {
31138
+ const { recordingEnabled } = useAppContext();
31139
+ const mocks = getMockRecordings() ?? NO_MOCKS;
31140
+ const version3 = useSyncExternalStore3(subscribeRecordings, recordingsVersion);
31141
+ const hostBusy = useSyncExternalStore3(subscribeRecordings, anyRecordingLive);
31142
+ const key = aliases.join("\n");
31143
+ const states = useMemo4(
31144
+ () => Object.fromEntries(key.split("\n").filter((alias) => alias !== "").map((alias) => [alias, mocks[alias] ?? recordingStateOf(alias)])),
31145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
31146
+ [key, version3, mocks]
31147
+ );
31148
+ return {
31149
+ available: recordingEnabled || aliases.some((alias) => mocks[alias] !== void 0),
31150
+ states,
31151
+ busy: busyWith(mocks, hostBusy),
31152
+ start: startRecording,
31153
+ stop: stopRecording
31154
+ };
31155
+ }
31113
31156
 
31114
31157
  // src/geolocation.ts
31115
31158
  var EARTH_RADIUS_M = 6371e3;
@@ -31317,7 +31360,7 @@ function useRecents(key, options = {}) {
31317
31360
  }
31318
31361
 
31319
31362
  // src/use_url_state.ts
31320
- import { useCallback as useCallback9, useEffect as useEffect6, useMemo as useMemo4, useRef as useRef5, useState as useState5 } from "react";
31363
+ import { useCallback as useCallback9, useEffect as useEffect6, useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
31321
31364
  function useUrlState(defs) {
31322
31365
  const defsRef = useRef5(defs);
31323
31366
  defsRef.current = defs;
@@ -31337,7 +31380,7 @@ function useUrlState(defs) {
31337
31380
  unsubscribe();
31338
31381
  };
31339
31382
  }, []);
31340
- const values2 = useMemo4(() => decodeAll(defsRef.current, params), [params]);
31383
+ const values2 = useMemo5(() => decodeAll(defsRef.current, params), [params]);
31341
31384
  const setValues = useCallback9(
31342
31385
  (patch) => {
31343
31386
  editedRef.current = true;
@@ -31421,6 +31464,7 @@ export {
31421
31464
  useQuery,
31422
31465
  useRecents,
31423
31466
  useRecording,
31467
+ useRecordings,
31424
31468
  useUrlState,
31425
31469
  useViewer,
31426
31470
  useWorkflow,
@@ -44,4 +44,22 @@ export interface UseRecording<I> {
44
44
  */
45
45
  export declare function useRecording<K extends keyof AppWorkflows & string>(alias: K): UseRecording<ActInputsOf<K>>;
46
46
  export declare function useRecording(alias: string): UseRecording<Record<string, unknown>>;
47
+ export interface UseRecordings {
48
+ /** As {@link UseRecording}'s: whether the host records here at all. */
49
+ available: boolean;
50
+ /** The latest recording through each alias asked, idle where none ran. */
51
+ states: Readonly<Record<string, RecordingState>>;
52
+ /** Any of this app's recordings is live, through any alias. */
53
+ busy: boolean;
54
+ start: (alias: string, inputs: Record<string, unknown>) => Promise<{
55
+ started: boolean;
56
+ }>;
57
+ stop: (alias: string) => Promise<void>;
58
+ }
59
+ /**
60
+ * {@link useRecording} over several aliases at once — for a screen drawing an
61
+ * unknown number of recording acts, which cannot call a hook per alias. One
62
+ * snapshot per change: the host's every-second push while live redraws once.
63
+ */
64
+ export declare function useRecordings(aliases: readonly string[]): UseRecordings;
47
65
  export {};
@@ -33,6 +33,7 @@ export type RecordingState = {
33
33
  inputs: RecordingInputs;
34
34
  };
35
35
  export declare const IDLE_RECORDING: RecordingState;
36
+ export declare function recordingsVersion(): number;
36
37
  export declare function subscribeRecordings(listener: () => void): () => void;
37
38
  export declare function recordingStateOf(alias: string): RecordingState;
38
39
  export declare function anyRecordingLive(): boolean;
package/dist/router.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  isEmbedded,
3
3
  setUrlParams
4
- } from "./chunk-ZOFKWF6V.js";
4
+ } from "./chunk-CRJQYSDI.js";
5
5
 
6
6
  // src/router.tsx
7
7
  import { useEffect } from "react";
package/docs/ai.md CHANGED
@@ -29,6 +29,7 @@ A declaration carries:
29
29
  | `prefix_cache_ttl` | Optional prompt-cache window for the agent's stable prefix (tools + system). **Omit it** — the default `"5m"` is right for essentially every agent. `"1h"` writes at 2x the input rate (against 1.25x) to buy only the five-minute-to-one-hour band; set it only with measured cadence showing runs land there, 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
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
+ | `writes` | Optional — where the result lands: `{ table_id, row, fields }`. `row` is a single `record_link` input to `table_id`; `fields` maps each output to a field key of that table. Once a run's result validates, each output it carries is written to that row under the app's authority, with the table's locks and hooks applied; an output the result omits is left alone. A write that fails fails the run. Requires `outputs` |
32
33
 
33
34
  **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
35
 
package/docs/mutations.md CHANGED
@@ -531,8 +531,9 @@ every read before the request answers:
531
531
  - **Totals.** A `total`, a count per value (`total: { by }`), a group asked with `aggregate` or
532
532
  declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
533
533
  or takes away; a group the last row leaves is gone, and a count per value gains the value a row
534
- first holds. A mean, an extreme, a distinct count, a group by day, a group none of the read's rows
535
- counted in yet, a read whose rows a search or a cap decides, or a row the app never read is left
534
+ first holds. A mean, an extreme, a distinct count, a group by day, an aggregate with its own
535
+ `filter`, a group none of the read's rows counted in yet, a read whose rows a search or a cap
536
+ decides, or a row the app never read is left
536
537
  as the server said it until a read asked after the write answers.
537
538
  - **A refusal takes it back**, and `result` says why, as always. A success keeps it until a read
538
539
  asked after the write settled answers — the stored value then replaces the prediction, whatever
@@ -673,6 +674,11 @@ replaces the first's state):
673
674
  | `filed` | `execution_id`, `inputs` | the workflow ran and succeeded |
674
675
  | `failed` | `message`, `inputs` | transcription failed, or filing was refused or its run failed |
675
676
 
677
+ A screen drawing a number of recording acts it only learns at run time reads them together with
678
+ **`useRecordings(aliases)`**: `available` and `busy` as above, `states` (each alias's latest
679
+ recording, idle where none ran), and `start(alias, inputs)` / `stop(alias)` — the same calls, by
680
+ alias.
681
+
676
682
  `inputs` is what `start` was passed, so a screen with one act per row can tell which row the
677
683
  recording is about. **`busy`** is true while any recording this app started is live, through
678
684
  any alias: stand the other acts down, since the host would refuse them. A recording in another
package/docs/queries.md CHANGED
@@ -277,7 +277,7 @@ came from.
277
277
 
278
278
  Full semantics in §8. `by` may be empty (a single-row aggregate); `aggregates` needs ≥ 1 entry.
279
279
  Aggregate operation × input-column type is validated at bind. Grouping collapses rows —
280
- addressing is dropped.
280
+ addressing is dropped. An aggregate's `filter` limits it to the rows that match (§8).
281
281
 
282
282
  ### `window` — aggregate without collapsing
283
283
 
@@ -679,6 +679,25 @@ everything else requires an `input_column` whose type must be compatible — che
679
679
  ¹ opaque `json` columns support only the presence-counting six (`empty`/`filled`/`unique` and
680
680
  their `percent_*` forms).
681
681
 
682
+ **`filter` — aggregate a subset.** Any aggregate, in `group` or `window`, takes a `filter` over its
683
+ input columns — the same tree as a `filter` node, `{{params.x}}` included, with a condition on an
684
+ omitted optional param dropped. The aggregate reads only the rows it matches (SQL `FILTER`), and
685
+ the rest of the node is unaffected, so several figures over one table cost one read of it:
686
+
687
+ ```jsonc
688
+ { "kind": "group", "from": { "kind": "from_table", "table_id": "…" }, "by": [],
689
+ "aggregates": [
690
+ { "output": "open", "type": "number", "operation": "count",
691
+ "filter": { "node_type": "condition", "field_key": "closed_at", "operator": "is_empty" } },
692
+ { "output": "closed_today", "type": "number", "operation": "count",
693
+ "filter": { "node_type": "condition", "field_key": "closed_at", "operator": "on",
694
+ "value": { "type": "period", "period": "day", "boundary": "start", "offset": 0 } } } ] }
695
+ ```
696
+
697
+ A relative date resolves in its field's timezone while the column still names one field — over a
698
+ `from_table`, or a `project` of one. Over a `union` of different tables it names none; count per
699
+ table, then combine.
700
+
682
701
  **`string_agg` — a summary column, not a dataset.** Every other operation counts or reduces to a
683
702
  number; this one joins the values, so a child set answers "which ones?" in the parent row (the
684
703
  sizes on a shipment, the tags on a ticket) without a second query.
package/docs/workflows.md CHANGED
@@ -150,6 +150,7 @@ The body is a sequence of statements, each of which maps 1:1 onto a stored step.
150
150
  | Pause for an event | `await wait_for_event({ event_type: "payment" \| "webhook", event_ref, timeout_in_minutes? });` |
151
151
  | Pause for a decision | `const a = await wait_for_approval({ approvers, prompt, timeout_in_minutes? });` |
152
152
  | LLM reasoning | `const x = await agent({ instructions, input, tools, model?, output });` |
153
+ | Run a declared agent | `const x = await app_agent({ alias, input, wait? });` |
153
154
  | Guard | `validate({ checks: [{ fail_when, field_key?, message }, …] });` |
154
155
  | End the run | `return({ status, message, field_errors?, data? });` |
155
156
 
@@ -313,6 +314,22 @@ name" when broken:
313
314
  `model` is an optional pin — omit it to follow the platform default. The step is atomic (no wait
314
315
  or approval inside it).
315
316
 
317
+ ### `app_agent` — run one of this app's agents as one step
318
+
319
+ ```js
320
+ const scored = await app_agent({ alias: "scorer", input: { deal: trigger.app_workflow.inputs.deal } });
321
+ ```
322
+
323
+ `alias` names an agent this app declares; `input` is an object literal checked against its
324
+ `inputs`. The step resolves to the agent's declared `outputs` — typed, so `scored.summary` is
325
+ checked at save — or to its final text when it declares none, and applies the agent's `writes`.
326
+ The run shows in the agent's history like any other. It exists only in an app's own workflows,
327
+ and a run an agent started (through `run_app_workflow`) refuses it: an agent never starts an agent.
328
+
329
+ `wait: false` starts the run and resolves at once to `{ run_id }`. The workflow goes on, and what
330
+ it wrote stands; the agent's result — its `writes` included — lands when the run settles, and a
331
+ run that fails fails in the agent's history, not the workflow.
332
+
316
333
  ### `try` / `catch`
317
334
 
318
335
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.102.2",
3
+ "version": "0.103.0",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {