@isomorph.ai/app-sdk 1.7.0 → 1.9.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/README.md CHANGED
@@ -36,6 +36,93 @@ Replace the connection/resource with the app's existing approved integration. To
36
36
 
37
37
  For images call `generateImage({model: gateway.image(process.env.ISOMORPH_AI_IMAGE_MODEL_ALIAS!), prompt: 'A blue notebook'})`. Editing uses `prompt: {text: 'Make it red', images: [inputBytes]}` only when `isomorph ai models` advertises edit. Reuse app files upload/signed reads for authorized input and output storage; respect existing transfer/action limits and never return credentials.
38
38
 
39
+ ## Company integrations
40
+
41
+ `isomorph.integrations.execute(connection, request)` is one method over a closed
42
+ set of operations; the operation fixes whose identity it runs under, and the
43
+ contract's bounds are checked in the client before any request is sent (the
44
+ same sentence the platform answers with). The connection, operation, resource
45
+ and `mode` are literals in the call: the kit gate derives
46
+ `.isomorph/integrations.json` from them. `isomorph dev` without `--real`
47
+ answers every operation from a local fixture.
48
+
49
+ Slack. A history, post, edit or delete runs as the app or as the signed-in
50
+ person (`mode`, the one IT approved for the app); a DM and a user lookup run
51
+ as the app only, against the fixed `workspace` resource. A post or DM needs a
52
+ fresh UUID per explicit Send press.
53
+
54
+ ```ts
55
+ // A thread reply in Slack mrkdwn
56
+ const page = await isomorph.integrations.execute('company-slack', {
57
+ operation: 'slack.channel.history', resource: 'team-updates', input: { limit: 100 },
58
+ });
59
+ await isomorph.integrations.execute('company-slack', {
60
+ operation: 'slack.message.post', resource: 'team-updates',
61
+ input: { text: '*Done* — see <https://example.com/report|the report>', format: 'mrkdwn', threadId: page.messages[0].id },
62
+ idempotencyKey: crypto.randomUUID(), mode: 'app',
63
+ });
64
+
65
+ // Paged history: hasMore and nextCursor say when and how to read the next (older) page
66
+ const older = await isomorph.integrations.execute('company-slack', {
67
+ operation: 'slack.channel.history', resource: 'team-updates', input: { limit: 100, cursor: page.nextCursor },
68
+ });
69
+
70
+ // Edit or delete a message the app posted (its ts is the messageId)
71
+ const posted = await isomorph.integrations.execute('company-slack', {
72
+ operation: 'slack.message.post', resource: 'team-updates', input: { text: 'Deploy starting' }, idempotencyKey: crypto.randomUUID(), mode: 'app',
73
+ });
74
+ await isomorph.integrations.execute('company-slack', {
75
+ operation: 'slack.message.update', resource: 'team-updates', input: { messageId: posted.messageId, text: 'Deploy finished' }, mode: 'app',
76
+ });
77
+ await isomorph.integrations.execute('company-slack', {
78
+ operation: 'slack.message.delete', resource: 'team-updates', input: { messageId: posted.messageId }, mode: 'app',
79
+ });
80
+
81
+ // A direct message by email (1 to 8 people; several open one group DM) and a user lookup
82
+ await isomorph.integrations.execute('company-slack', {
83
+ operation: 'slack.dm.send', resource: 'workspace', input: { to: ['jane@example.com'], text: 'Your review is due today.' },
84
+ idempotencyKey: crypto.randomUUID(),
85
+ });
86
+ const { userId } = await isomorph.integrations.execute('company-slack', {
87
+ operation: 'slack.user.lookup', resource: 'workspace', input: { email: 'jane@example.com' },
88
+ });
89
+ ```
90
+
91
+ Gmail runs as the signed-in person after their consent, on the fixed `inbox`
92
+ resource.
93
+
94
+ ```ts
95
+ // Search, then read a whole thread
96
+ const found = await isomorph.integrations.execute('company-gmail', {
97
+ operation: 'gmail.thread.list', resource: 'inbox', input: { q: 'from:jane newer_than:7d', limit: 50 },
98
+ });
99
+ const thread = await isomorph.integrations.execute('company-gmail', {
100
+ operation: 'gmail.thread.read', resource: 'inbox', input: { threadId: found.threads[0].id },
101
+ });
102
+ // found.nextPageToken, when hasMore, is the next read's pageToken
103
+
104
+ // An attachment: a message or thread read names them (id, filename, mimeType, size); this fetches the bytes
105
+ const file = thread.emails[0].attachments?.[0];
106
+ if (file) {
107
+ const { dataBase64 } = await isomorph.integrations.execute('company-gmail', {
108
+ operation: 'gmail.attachment.read', resource: 'inbox', input: { messageId: thread.emails[0].id, attachmentId: file.id },
109
+ });
110
+ }
111
+
112
+ // An HTML send: text stays the plain-text alternative; up to 5 attachments of 2 MiB each
113
+ await isomorph.integrations.execute('company-gmail', {
114
+ operation: 'gmail.message.send', resource: 'inbox',
115
+ input: {
116
+ to: ['jane@example.com'], subject: 'Weekly report', text: 'The report is attached.',
117
+ html: '<p>The report is <b>attached</b>.</p>',
118
+ attachments: [{ filename: 'report.csv', contentType: 'text/csv', dataBase64: csvBase64 }],
119
+ },
120
+ idempotencyKey: crypto.randomUUID(),
121
+ });
122
+ ```
123
+
124
+ (1.8.0; contracts 0.71.0)
125
+
39
126
  ## Browser read tools
40
127
 
41
128
  Install optional peers `ai` and `playwright-core@1.58.2`. Locally run `npx playwright-core install chromium --only-shell`; V2 packages Chromium in the existing app image. An existing system executable can be supplied as executablePath.
@@ -113,3 +200,25 @@ export default async function approve(input: { id: string }, ctx: { user: { grou
113
200
  ```
114
201
 
115
202
  (1.6.0)
203
+
204
+ ## Live progress from an action
205
+
206
+ An action reports steps with `ctx.progress(step)` (a string or a small JSON
207
+ object); a page that passes `onProgress` gets each one as it happens, then the
208
+ result or the error exactly as a plain `invoke` gives it. No table, no polling.
209
+
210
+ ```ts
211
+ // actions/research.ts
212
+ export default async function research(input: { url: string }, ctx: { progress(step: string): void }) {
213
+ ctx.progress(`Opened ${input.url}`);
214
+ // ...
215
+ return result;
216
+ }
217
+
218
+ // page
219
+ await isomorph.actions.invoke('research', input, { onProgress: (step) => setSteps((steps) => [...steps, step]) });
220
+ ```
221
+
222
+ Without `onProgress` the call is unchanged and `ctx.progress` does nothing. At
223
+ most 256 steps of up to 1 KiB each reach the page; further steps are dropped.
224
+ The action still has the 300 s action budget. (Unreleased)
package/dist/index.d.ts CHANGED
@@ -24,30 +24,94 @@ export interface IsomorphResult<T> {
24
24
  data: T;
25
25
  count?: number;
26
26
  }
27
+ /** How Slack renders a message's `text`: plain text, or Slack mrkdwn (`*bold*`, `<url|label>`, `<@U123>`); plain when absent. */
28
+ export type SlackTextFormat = 'plain' | 'mrkdwn';
29
+ /**
30
+ * Which of the modes IT approved for this app a declared-mode Slack operation runs under: `app` (the company's
31
+ * Slack app, under the IT-approved per-app name and footer) or `user` (the signed-in person, after their consent).
32
+ * Leave it out when the app is approved for one mode; an app approved for both must say (MODE_REQUIRED), and a
33
+ * mode IT has not approved is refused with MODE_NOT_GRANTED — never served under the other mode.
34
+ */
35
+ export type SlackIdentityMode = 'app' | 'user';
36
+ /** Reads a channel's newest messages as the signed-in person (or as the app, when declared so); pages backwards with `cursor`. */
27
37
  export interface SlackChannelHistoryRequest {
28
38
  operation: 'slack.channel.history';
29
39
  resource: string;
30
40
  input?: {
31
- /** How many newest messages to read: an integer from 1 to 15 (the platform's ceiling); default 15. */
41
+ /** How many messages to read: an integer from 1 to 100 (the platform's ceiling); default 15. */
32
42
  limit?: number;
43
+ /** The `nextCursor` of the previous page; leave it out to read the newest page. */
44
+ cursor?: string;
45
+ /** Only messages after this Slack ts (a message id). */
46
+ oldest?: string;
47
+ /** Only messages before this Slack ts (a message id). */
48
+ latest?: string;
33
49
  };
50
+ mode?: SlackIdentityMode;
34
51
  }
52
+ /** Posts one message to a channel, as the app or as the signed-in person; `threadId` makes it a thread reply. */
35
53
  export interface SlackMessagePostRequest {
36
54
  operation: 'slack.message.post';
37
55
  resource: string;
38
56
  input: {
39
- /** Plain text, 1 to 4000 characters. Posted as the app (under the IT-approved per-app name) or as the signed-in person (their consent), the same text either way. */
57
+ /** The body, 1 to 4000 characters; plain text unless `format` is `mrkdwn`. Posted as the app (under the IT-approved per-app name) or as the signed-in person (their consent), the same text either way. */
40
58
  text: string;
59
+ /** The Slack ts of the message this one replies to (a history message id), so the post lands in that thread. */
60
+ threadId?: string;
61
+ format?: SlackTextFormat;
41
62
  };
42
63
  /** A fresh UUID per explicit Send press, reused only to retry that press. */
43
64
  idempotencyKey: string;
44
- /**
45
- * Which of the modes IT approved for this app the post runs under: `app` (the company's Slack app, under the
46
- * IT-approved per-app name and footer) or `user` (the signed-in person, after their consent). Leave it out when
47
- * the app is approved for one mode; an app approved for both must say (MODE_REQUIRED), and a mode IT has not
48
- * approved is refused with MODE_NOT_GRANTED — never served under the other mode.
49
- */
50
- mode?: 'app' | 'user';
65
+ mode?: SlackIdentityMode;
66
+ }
67
+ /** Edits a message the app (or the signed-in person, in user mode) posted; the whole `text` is replaced. */
68
+ export interface SlackMessageUpdateRequest {
69
+ operation: 'slack.message.update';
70
+ resource: string;
71
+ input: {
72
+ /** The Slack ts a post or history result named as `messageId`. */
73
+ messageId: string;
74
+ /** The new body, 1 to 4000 characters. */
75
+ text: string;
76
+ format?: SlackTextFormat;
77
+ };
78
+ mode?: SlackIdentityMode;
79
+ }
80
+ /** Deletes a message the app (or the signed-in person, in user mode) posted. */
81
+ export interface SlackMessageDeleteRequest {
82
+ operation: 'slack.message.delete';
83
+ resource: string;
84
+ input: {
85
+ /** The Slack ts a post or history result named as `messageId`. */
86
+ messageId: string;
87
+ };
88
+ mode?: SlackIdentityMode;
89
+ }
90
+ /**
91
+ * Sends a direct message as the company's Slack app to workspace members named by email (always app mode; the
92
+ * resource is the fixed `workspace` alias). Several recipients open one group DM.
93
+ */
94
+ export interface SlackDmSendRequest {
95
+ operation: 'slack.dm.send';
96
+ resource: string;
97
+ input: {
98
+ /** 1 to 8 email addresses of workspace members, no repeats. */
99
+ to: string[];
100
+ /** The body, 1 to 4000 characters. */
101
+ text: string;
102
+ format?: SlackTextFormat;
103
+ };
104
+ /** A fresh UUID per explicit Send press, reused only to retry that press. */
105
+ idempotencyKey: string;
106
+ }
107
+ /** Resolves a workspace member's email to their Slack user id, as the app (the resource is the fixed `workspace` alias). */
108
+ export interface SlackUserLookupRequest {
109
+ operation: 'slack.user.lookup';
110
+ resource: string;
111
+ input: {
112
+ /** The member's email address. */
113
+ email: string;
114
+ };
51
115
  }
52
116
  /**
53
117
  * One condition on a warehouse read; a read's conditions are AND-ed. `isNull`/`isNotNull` take no value, `in`/`notIn`
@@ -93,12 +157,28 @@ export interface WarehouseViewReadRequest {
93
157
  limit?: number;
94
158
  };
95
159
  }
96
- /** Gmail runs as the signed-in person after their consent. Resources are approved mailbox aliases. */
160
+ /** Gmail runs as the signed-in person after their consent. Resources are approved mailbox aliases (`inbox`). */
97
161
  export interface GmailThreadListRequest {
98
162
  operation: 'gmail.thread.list';
99
163
  resource: string;
100
164
  input?: {
165
+ /** Newest matching threads, 1 to 50; default 15. */
101
166
  limit?: number;
167
+ /** A Gmail search query in the search-box syntax, e.g. `from:jane newer_than:7d`, 1 to 256 characters. */
168
+ q?: string;
169
+ /** 1 to 5 labels a thread must carry (all of them); `['INBOX']` when absent. */
170
+ labelIds?: string[];
171
+ /** The `nextPageToken` of the previous page; leave it out to read the first page. */
172
+ pageToken?: string;
173
+ };
174
+ }
175
+ /** Reads every message of one thread, oldest first, as the signed-in person. */
176
+ export interface GmailThreadReadRequest {
177
+ operation: 'gmail.thread.read';
178
+ resource: string;
179
+ input: {
180
+ /** The thread id a `gmail.thread.list` result named. */
181
+ threadId: string;
102
182
  };
103
183
  }
104
184
  export interface GmailMessageReadRequest {
@@ -108,24 +188,47 @@ export interface GmailMessageReadRequest {
108
188
  messageId: string;
109
189
  };
110
190
  }
191
+ /** Reads one attachment's bytes (at most 2 MiB) as the signed-in person; the ids come from a message or thread read. */
192
+ export interface GmailAttachmentReadRequest {
193
+ operation: 'gmail.attachment.read';
194
+ resource: string;
195
+ input: {
196
+ messageId: string;
197
+ /** The `id` of an entry in the message's `attachments`. */
198
+ attachmentId: string;
199
+ };
200
+ }
201
+ /** One file to attach to a send, carried inline as base64: at most 2 MiB of bytes (2,796,203 base64 characters). */
202
+ export interface GmailAttachmentInput {
203
+ /** The name the recipient sees, 1 to 255 characters. */
204
+ filename: string;
205
+ /** The MIME type, e.g. `application/pdf`. */
206
+ contentType: string;
207
+ /** The file bytes, standard base64. */
208
+ dataBase64: string;
209
+ }
111
210
  export interface GmailMessageSendRequest {
112
211
  operation: 'gmail.message.send';
113
212
  resource: string;
114
213
  input: {
115
- /** 1 to 50 email addresses per recipient list. */
214
+ /** 1 to 50 email addresses per recipient list (at most 50 across to, cc and bcc). */
116
215
  to: string[];
117
216
  cc?: string[];
118
217
  bcc?: string[];
119
218
  /** 1 to 256 characters. */
120
219
  subject: string;
121
- /** Plain text, 1 to 4000 characters. Attachments and HTML are not supported. */
220
+ /** Plain text, 1 to 4000 characters; the plain-text alternative when `html` is sent too. */
122
221
  text: string;
222
+ /** An HTML body sent beside `text`, at most 64 KiB. */
223
+ html?: string;
224
+ /** 1 to 5 files, each at most 2 MiB. */
225
+ attachments?: GmailAttachmentInput[];
123
226
  replyToMessageId?: string;
124
227
  };
125
228
  /** A fresh UUID per authorized Send action, reused only to retry that action. */
126
229
  idempotencyKey: string;
127
230
  }
128
- export type IntegrationRequest = SlackChannelHistoryRequest | SlackMessagePostRequest | WarehouseViewReadRequest | GmailThreadListRequest | GmailMessageReadRequest | GmailMessageSendRequest;
231
+ export type IntegrationRequest = SlackChannelHistoryRequest | SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest | SlackDmSendRequest | SlackUserLookupRequest | WarehouseViewReadRequest | GmailThreadListRequest | GmailThreadReadRequest | GmailMessageReadRequest | GmailAttachmentReadRequest | GmailMessageSendRequest;
129
232
  export type IntegrationOperation = IntegrationRequest['operation'];
130
233
  export interface SlackMessage {
131
234
  id: string;
@@ -140,10 +243,23 @@ export interface SlackMessage {
140
243
  export interface SlackChannelHistoryResult {
141
244
  messages: SlackMessage[];
142
245
  hasMore: boolean;
246
+ /** The cursor of the next (older) page, present only when `hasMore`; pass it as the next read's `cursor`. */
247
+ nextCursor?: string;
143
248
  }
249
+ /** The Slack ts of the message posted, edited or deleted. */
144
250
  export interface SlackMessagePostResult {
145
251
  messageId: string;
146
252
  }
253
+ export interface SlackDmSendResult {
254
+ messageId: string;
255
+ /** The DM channel the message went to. */
256
+ channelId: string;
257
+ }
258
+ export interface SlackUserLookupResult {
259
+ userId: string;
260
+ /** The member's display name, or real name when they set none; may be empty. */
261
+ displayName: string;
262
+ }
147
263
  export interface WarehouseViewReadResult {
148
264
  rows: Array<Record<string, unknown>>;
149
265
  /** True when more rows matched than `limit`: the rows are incomplete, so never total or average them in code. */
@@ -160,6 +276,16 @@ export interface GmailThreadSummary {
160
276
  export interface GmailThreadListResult {
161
277
  threads: GmailThreadSummary[];
162
278
  hasMore: boolean;
279
+ /** The token of the next page, present only when `hasMore`; pass it as the next read's `pageToken`. */
280
+ nextPageToken?: string;
281
+ }
282
+ /** One attachment of a message, named without its bytes; `gmail.attachment.read` fetches them by `id`. */
283
+ export interface GmailAttachment {
284
+ id: string;
285
+ filename: string;
286
+ mimeType: string;
287
+ /** Bytes. */
288
+ size: number;
163
289
  }
164
290
  export interface GmailMessageReadResult {
165
291
  id: string;
@@ -168,14 +294,27 @@ export interface GmailMessageReadResult {
168
294
  from: string;
169
295
  to: string;
170
296
  date: string;
297
+ /** The plain text with HTML stripped, cut at 16 KiB with `truncated` set. */
171
298
  text: string;
172
299
  truncated: boolean;
300
+ attachments?: GmailAttachment[];
301
+ }
302
+ /** Every message of the thread, oldest first, each in the `gmail.message.read` shape. */
303
+ export interface GmailThreadReadResult {
304
+ id: string;
305
+ emails: GmailMessageReadResult[];
306
+ }
307
+ export interface GmailAttachmentReadResult {
308
+ filename: string;
309
+ mimeType: string;
310
+ size: number;
311
+ dataBase64: string;
173
312
  }
174
313
  export interface GmailMessageSendResult {
175
314
  messageId: string;
176
315
  threadId: string;
177
316
  }
178
- export type IntegrationResult<R extends IntegrationRequest> = R extends SlackChannelHistoryRequest ? SlackChannelHistoryResult : R extends SlackMessagePostRequest ? SlackMessagePostResult : R extends WarehouseViewReadRequest ? WarehouseViewReadResult : R extends GmailThreadListRequest ? GmailThreadListResult : R extends GmailMessageReadRequest ? GmailMessageReadResult : R extends GmailMessageSendRequest ? GmailMessageSendResult : never;
317
+ export type IntegrationResult<R extends IntegrationRequest> = R extends SlackChannelHistoryRequest ? SlackChannelHistoryResult : R extends SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest ? SlackMessagePostResult : R extends SlackDmSendRequest ? SlackDmSendResult : R extends SlackUserLookupRequest ? SlackUserLookupResult : R extends WarehouseViewReadRequest ? WarehouseViewReadResult : R extends GmailThreadListRequest ? GmailThreadListResult : R extends GmailThreadReadRequest ? GmailThreadReadResult : R extends GmailMessageReadRequest ? GmailMessageReadResult : R extends GmailAttachmentReadRequest ? GmailAttachmentReadResult : R extends GmailMessageSendRequest ? GmailMessageSendResult : never;
179
318
  export type IntegrationConnectResult = {
180
319
  status: 'connected';
181
320
  accountLabel: string;
@@ -302,7 +441,7 @@ declare class Transport {
302
441
  private readonly identity;
303
442
  renewal: IdentityRenewal | undefined;
304
443
  constructor(options: ClientOptions, onUnauthorized?: (() => void) | undefined);
305
- json<T>(path: string, init?: RequestInit): Promise<T>;
444
+ json<T>(path: string, init?: RequestInit, onProgress?: (step: never) => void): Promise<T>;
306
445
  private request;
307
446
  }
308
447
  export declare class QueryBuilder<T = Record<string, unknown>, Result = T[]> implements PromiseLike<IsomorphResult<Result>> {
@@ -393,7 +532,10 @@ export declare function createClient(options?: ClientOptions): {
393
532
  channel: (name: string) => RealtimeChannelBuilder;
394
533
  };
395
534
  actions: {
396
- invoke: <T = unknown>(name: string, input: unknown) => Promise<T>;
535
+ /** `onProgress` receives each `ctx.progress(step)` the action reports while it runs. */
536
+ invoke: <T = unknown, P = string>(name: string, input: unknown, options?: {
537
+ onProgress?: (step: P) => void;
538
+ }) => Promise<T>;
397
539
  };
398
540
  jobs: {
399
541
  enqueue: (name: string, input?: unknown) => Promise<{
package/dist/index.js CHANGED
@@ -28,6 +28,13 @@ function warehouseReadProblem({ columns, aggregates }) {
28
28
  return { field: 'columns', message: 'columns may use "*" only as the sole entry' };
29
29
  return undefined;
30
30
  }
31
+ // The operations whose request carries a `mode`, and the only ones it is sent
32
+ // for (every other operation's mode is fixed, and the platform refuses the
33
+ // field there). The key type is derived from the union, so adding or removing
34
+ // `mode` on a request without changing this table does not compile.
35
+ const declaredModeOperations = {
36
+ 'slack.channel.history': true, 'slack.message.post': true, 'slack.message.update': true, 'slack.message.delete': true,
37
+ };
31
38
  /** The AI reason code carried by an error, when it is a governed-AI refusal. */
32
39
  export function aiErrorCode(error) {
33
40
  const code = error instanceof IsomorphError ? error.details?.code : undefined;
@@ -177,18 +184,21 @@ class Transport {
177
184
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
178
185
  this.identity = options.identity;
179
186
  }
180
- json(path, init = {}) {
181
- return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH));
187
+ json(path, init = {}, onProgress) {
188
+ return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH), onProgress);
182
189
  }
183
- async request(path, init, renewOnUnauthorized) {
190
+ async request(path, init, renewOnUnauthorized, onProgress) {
184
191
  const sentAt = this.renewal?.renewals() ?? 0;
185
192
  // (headers with an undefined value are removed so a multipart body can
186
193
  // clear the JSON default and let the browser set its own boundary)
187
194
  const response = await this.fetch(`${this.baseUrl}${path}`, {
188
195
  credentials: 'same-origin',
189
196
  ...init,
190
- headers: mergeHeaders({ 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers),
197
+ headers: mergeHeaders({ 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers, onProgress ? { accept: PROGRESS_MEDIA_TYPE } : undefined),
191
198
  });
199
+ if (onProgress && response.ok && response.body && response.headers.get('content-type')?.startsWith(PROGRESS_MEDIA_TYPE)) {
200
+ return readProgress(response, onProgress);
201
+ }
192
202
  const body = await response.json().catch(() => undefined);
193
203
  if (!response.ok) {
194
204
  if (response.status === 401) {
@@ -204,7 +214,7 @@ class Transport {
204
214
  if (renewed && path === IDENTITY_PATH)
205
215
  return renewed;
206
216
  if (renewed !== false)
207
- return this.request(path, init, false);
217
+ return this.request(path, init, false, onProgress);
208
218
  }
209
219
  // Not before the resend: a page listening to identity.onChange would
210
220
  // otherwise see the user flicker to null while the edge was healing.
@@ -215,6 +225,44 @@ class Transport {
215
225
  return (body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body);
216
226
  }
217
227
  }
228
+ // An action invoked with onProgress asks for its progress stream: one JSON
229
+ // line per ctx.progress step, then the answer — `{data}` or `{error, status}`,
230
+ // the refusal a plain call would have thrown. A runtime that does not stream
231
+ // answers plain JSON and the call reads it as before.
232
+ const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
233
+ async function readProgress(response, onProgress) {
234
+ const requestId = response.headers.get('x-request-id') ?? undefined;
235
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
236
+ let pending = '';
237
+ try {
238
+ for (;;) {
239
+ const { done, value } = await reader.read();
240
+ const lines = (pending + (value ?? '')).split('\n');
241
+ pending = done ? '' : lines.pop();
242
+ for (const line of lines.filter((text) => text.trim())) {
243
+ let event;
244
+ try {
245
+ event = JSON.parse(line);
246
+ }
247
+ catch {
248
+ throw new IsomorphError('INTERNAL', 'The action answered with an unreadable progress line.', requestId);
249
+ }
250
+ if ('progress' in event) {
251
+ onProgress(event.progress);
252
+ continue;
253
+ }
254
+ if (event.error)
255
+ throw new IsomorphError(event.error.category ?? 'INTERNAL', event.error.message ?? 'The action failed.', requestId, event.error.details, event.status);
256
+ return event.data;
257
+ }
258
+ if (done)
259
+ throw new IsomorphError('UNAVAILABLE', 'The action stopped before it answered.', requestId);
260
+ }
261
+ }
262
+ finally {
263
+ void reader.cancel().catch(() => undefined);
264
+ }
265
+ }
218
266
  function applicationBaseUrl() {
219
267
  if (typeof location === 'undefined') {
220
268
  // No browser: this module is running under the retained-check runner in
@@ -631,24 +679,24 @@ export function createClient(options = {}) {
631
679
  channel: (name) => new RealtimeChannelBuilder(name, connectRealtime),
632
680
  },
633
681
  actions: {
634
- invoke: (name, input) => {
682
+ /** `onProgress` receives each `ctx.progress(step)` the action reports while it runs. */
683
+ invoke: (name, input, options = {}) => {
684
+ const path = `/_harbour/actions/${encodeURIComponent(name)}`;
635
685
  // supabase-js accepts FormData/Blob bodies and sends them as
636
686
  // multipart; JSON.stringify(FormData) silently becomes "{}", which
637
687
  // turned a verbatim-ported multipart action into a guaranteed 500.
638
688
  // Pass binary bodies through and let the browser set the boundary.
639
689
  if (typeof FormData !== 'undefined' && input instanceof FormData) {
640
- return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
690
+ return transport.json(path, {
641
691
  method: 'POST', body: input, headers: { 'content-type': undefined },
642
- });
692
+ }, options.onProgress);
643
693
  }
644
694
  if (typeof Blob !== 'undefined' && input instanceof Blob) {
645
- return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
695
+ return transport.json(path, {
646
696
  method: 'POST', body: input, headers: { 'content-type': input.type || 'application/octet-stream' },
647
- });
697
+ }, options.onProgress);
648
698
  }
649
- return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
650
- method: 'POST', body: JSON.stringify(input),
651
- });
699
+ return transport.json(path, { method: 'POST', body: JSON.stringify(input) }, options.onProgress);
652
700
  },
653
701
  },
654
702
  jobs: {
@@ -676,12 +724,11 @@ export function createClient(options = {}) {
676
724
  if (problem) {
677
725
  return Promise.reject(new IsomorphError('VALIDATION_FAILED', problem.message, undefined, { code: 'INPUT_INVALID', field: problem.field }));
678
726
  }
679
- // Exactly the contract's fields go on the wire: `mode` is one of them
680
- // for a Slack post only (which approved mode it runs under); an
681
- // `identity` or any other stray field is not sent.
727
+ // Exactly the contract's fields go on the wire: `mode` only for the
728
+ // declaredModeOperations; an `identity` or any other stray field is not sent.
682
729
  const { operation, resource, input, idempotencyKey, mode } = request;
683
730
  return transport.json('/_harbour/integrations/execute', {
684
- method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation === 'slack.message.post' && mode !== undefined ? { mode } : {}) }),
731
+ method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation in declaredModeOperations && mode !== undefined ? { mode } : {}) }),
685
732
  });
686
733
  },
687
734
  // In a browser the consent returns the person to this page (without its
@@ -28,6 +28,7 @@ export type IntegrationExecuteInputFieldRule = {
28
28
  maxLength?: number;
29
29
  pattern?: string;
30
30
  };
31
+ default?: readonly string[];
31
32
  } | {
32
33
  kind: 'enum';
33
34
  required: boolean;
@@ -64,9 +65,27 @@ export declare const IntegrationExecuteInputRules: {
64
65
  readonly kind: "integer";
65
66
  readonly required: false;
66
67
  readonly minimum: 1;
67
- readonly maximum: 15;
68
+ readonly maximum: 100;
68
69
  readonly default: 15;
69
70
  };
71
+ readonly cursor: {
72
+ readonly kind: "string";
73
+ readonly required: false;
74
+ readonly minLength: 1;
75
+ readonly maxLength: 256;
76
+ };
77
+ readonly oldest: {
78
+ readonly kind: "string";
79
+ readonly required: false;
80
+ readonly minLength: 1;
81
+ readonly maxLength: 64;
82
+ };
83
+ readonly latest: {
84
+ readonly kind: "string";
85
+ readonly required: false;
86
+ readonly minLength: 1;
87
+ readonly maxLength: 64;
88
+ };
70
89
  };
71
90
  };
72
91
  readonly "slack.message.post": {
@@ -78,6 +97,93 @@ export declare const IntegrationExecuteInputRules: {
78
97
  readonly minLength: 1;
79
98
  readonly maxLength: 4000;
80
99
  };
100
+ readonly threadId: {
101
+ readonly kind: "string";
102
+ readonly required: false;
103
+ readonly minLength: 0;
104
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
105
+ };
106
+ readonly format: {
107
+ readonly kind: "enum";
108
+ readonly required: false;
109
+ readonly values: readonly ["plain", "mrkdwn"];
110
+ readonly default: "plain";
111
+ };
112
+ };
113
+ };
114
+ readonly "slack.message.update": {
115
+ readonly idempotencyKey: false;
116
+ readonly fields: {
117
+ readonly messageId: {
118
+ readonly kind: "string";
119
+ readonly required: true;
120
+ readonly minLength: 0;
121
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
122
+ };
123
+ readonly text: {
124
+ readonly kind: "string";
125
+ readonly required: true;
126
+ readonly minLength: 1;
127
+ readonly maxLength: 4000;
128
+ };
129
+ readonly format: {
130
+ readonly kind: "enum";
131
+ readonly required: false;
132
+ readonly values: readonly ["plain", "mrkdwn"];
133
+ readonly default: "plain";
134
+ };
135
+ };
136
+ };
137
+ readonly "slack.message.delete": {
138
+ readonly idempotencyKey: false;
139
+ readonly fields: {
140
+ readonly messageId: {
141
+ readonly kind: "string";
142
+ readonly required: true;
143
+ readonly minLength: 0;
144
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
145
+ };
146
+ };
147
+ };
148
+ readonly "slack.dm.send": {
149
+ readonly idempotencyKey: true;
150
+ readonly fields: {
151
+ readonly to: {
152
+ readonly kind: "string[]";
153
+ readonly required: true;
154
+ readonly minItems: 1;
155
+ readonly maxItems: 8;
156
+ readonly uniqueItems: true;
157
+ readonly item: {
158
+ readonly minLength: 3;
159
+ readonly maxLength: 254;
160
+ readonly pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$";
161
+ };
162
+ };
163
+ readonly text: {
164
+ readonly kind: "string";
165
+ readonly required: true;
166
+ readonly minLength: 1;
167
+ readonly maxLength: 4000;
168
+ };
169
+ readonly format: {
170
+ readonly kind: "enum";
171
+ readonly required: false;
172
+ readonly values: readonly ["plain", "mrkdwn"];
173
+ readonly default: "plain";
174
+ };
175
+ };
176
+ };
177
+ readonly "slack.user.lookup": {
178
+ readonly idempotencyKey: false;
179
+ readonly fields: {
180
+ readonly email: {
181
+ readonly kind: "string";
182
+ readonly required: true;
183
+ readonly minLength: 3;
184
+ readonly maxLength: 254;
185
+ readonly pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$";
186
+ };
81
187
  };
82
188
  };
83
189
  readonly "gmail.thread.list": {
@@ -87,9 +193,44 @@ export declare const IntegrationExecuteInputRules: {
87
193
  readonly kind: "integer";
88
194
  readonly required: false;
89
195
  readonly minimum: 1;
90
- readonly maximum: 15;
196
+ readonly maximum: 50;
91
197
  readonly default: 15;
92
198
  };
199
+ readonly q: {
200
+ readonly kind: "string";
201
+ readonly required: false;
202
+ readonly minLength: 1;
203
+ readonly maxLength: 256;
204
+ };
205
+ readonly labelIds: {
206
+ readonly kind: "string[]";
207
+ readonly required: false;
208
+ readonly minItems: 1;
209
+ readonly maxItems: 5;
210
+ readonly uniqueItems: true;
211
+ readonly item: {
212
+ readonly minLength: 1;
213
+ readonly maxLength: 64;
214
+ };
215
+ readonly default: readonly ["INBOX"];
216
+ };
217
+ readonly pageToken: {
218
+ readonly kind: "string";
219
+ readonly required: false;
220
+ readonly minLength: 1;
221
+ readonly maxLength: 256;
222
+ };
223
+ };
224
+ };
225
+ readonly "gmail.thread.read": {
226
+ readonly idempotencyKey: false;
227
+ readonly fields: {
228
+ readonly threadId: {
229
+ readonly kind: "string";
230
+ readonly required: true;
231
+ readonly minLength: 0;
232
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
233
+ };
93
234
  };
94
235
  };
95
236
  readonly "gmail.message.read": {
@@ -99,7 +240,7 @@ export declare const IntegrationExecuteInputRules: {
99
240
  readonly kind: "string";
100
241
  readonly required: true;
101
242
  readonly minLength: 0;
102
- readonly pattern: "^[A-Za-z0-9_-]{1,64}$";
243
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
103
244
  };
104
245
  };
105
246
  };
@@ -154,6 +295,38 @@ export declare const IntegrationExecuteInputRules: {
154
295
  readonly minLength: 1;
155
296
  readonly maxLength: 4000;
156
297
  };
298
+ readonly html: {
299
+ readonly kind: "string";
300
+ readonly required: false;
301
+ readonly minLength: 1;
302
+ readonly maxLength: 65536;
303
+ };
304
+ readonly attachments: {
305
+ readonly kind: "object[]";
306
+ readonly required: false;
307
+ readonly minItems: 1;
308
+ readonly maxItems: 5;
309
+ readonly fields: {
310
+ readonly filename: {
311
+ readonly kind: "string";
312
+ readonly required: true;
313
+ readonly minLength: 1;
314
+ readonly maxLength: 255;
315
+ };
316
+ readonly contentType: {
317
+ readonly kind: "string";
318
+ readonly required: true;
319
+ readonly minLength: 1;
320
+ readonly maxLength: 128;
321
+ };
322
+ readonly dataBase64: {
323
+ readonly kind: "string";
324
+ readonly required: true;
325
+ readonly minLength: 1;
326
+ readonly maxLength: 2796203;
327
+ };
328
+ };
329
+ };
157
330
  readonly replyToMessageId: {
158
331
  readonly kind: "string";
159
332
  readonly required: false;
@@ -162,6 +335,23 @@ export declare const IntegrationExecuteInputRules: {
162
335
  };
163
336
  };
164
337
  };
338
+ readonly "gmail.attachment.read": {
339
+ readonly idempotencyKey: false;
340
+ readonly fields: {
341
+ readonly messageId: {
342
+ readonly kind: "string";
343
+ readonly required: true;
344
+ readonly minLength: 0;
345
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
346
+ };
347
+ readonly attachmentId: {
348
+ readonly kind: "string";
349
+ readonly required: true;
350
+ readonly minLength: 1;
351
+ readonly maxLength: 512;
352
+ };
353
+ };
354
+ };
165
355
  readonly "warehouse.view.read": {
166
356
  readonly idempotencyKey: false;
167
357
  readonly fields: {
@@ -4,25 +4,67 @@ export const IntegrationExecuteInputRules = {
4
4
  "slack.channel.history": {
5
5
  idempotencyKey: false,
6
6
  fields: {
7
- limit: { kind: "integer", required: false, minimum: 1, maximum: 15, default: 15 },
7
+ limit: { kind: "integer", required: false, minimum: 1, maximum: 100, default: 15 },
8
+ cursor: { kind: "string", required: false, minLength: 1, maxLength: 256 },
9
+ oldest: { kind: "string", required: false, minLength: 1, maxLength: 64 },
10
+ latest: { kind: "string", required: false, minLength: 1, maxLength: 64 },
8
11
  },
9
12
  },
10
13
  "slack.message.post": {
11
14
  idempotencyKey: true,
12
15
  fields: {
13
16
  text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
17
+ threadId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
18
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
19
+ },
20
+ },
21
+ "slack.message.update": {
22
+ idempotencyKey: false,
23
+ fields: {
24
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
25
+ text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
26
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
27
+ },
28
+ },
29
+ "slack.message.delete": {
30
+ idempotencyKey: false,
31
+ fields: {
32
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
33
+ },
34
+ },
35
+ "slack.dm.send": {
36
+ idempotencyKey: true,
37
+ fields: {
38
+ to: { kind: "string[]", required: true, minItems: 1, maxItems: 8, uniqueItems: true, item: { minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" } },
39
+ text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
40
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
41
+ },
42
+ },
43
+ "slack.user.lookup": {
44
+ idempotencyKey: false,
45
+ fields: {
46
+ email: { kind: "string", required: true, minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" },
14
47
  },
15
48
  },
16
49
  "gmail.thread.list": {
17
50
  idempotencyKey: false,
18
51
  fields: {
19
- limit: { kind: "integer", required: false, minimum: 1, maximum: 15, default: 15 },
52
+ limit: { kind: "integer", required: false, minimum: 1, maximum: 50, default: 15 },
53
+ q: { kind: "string", required: false, minLength: 1, maxLength: 256 },
54
+ labelIds: { kind: "string[]", required: false, minItems: 1, maxItems: 5, uniqueItems: true, item: { minLength: 1, maxLength: 64 }, default: ["INBOX"] },
55
+ pageToken: { kind: "string", required: false, minLength: 1, maxLength: 256 },
56
+ },
57
+ },
58
+ "gmail.thread.read": {
59
+ idempotencyKey: false,
60
+ fields: {
61
+ threadId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
20
62
  },
21
63
  },
22
64
  "gmail.message.read": {
23
65
  idempotencyKey: false,
24
66
  fields: {
25
- messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9_-]{1,64}$" },
67
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
26
68
  },
27
69
  },
28
70
  "gmail.message.send": {
@@ -33,9 +75,22 @@ export const IntegrationExecuteInputRules = {
33
75
  bcc: { kind: "string[]", required: false, minItems: 1, maxItems: 50, uniqueItems: true, item: { minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" } },
34
76
  subject: { kind: "string", required: true, minLength: 1, maxLength: 256 },
35
77
  text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
78
+ html: { kind: "string", required: false, minLength: 1, maxLength: 65536 },
79
+ attachments: { kind: "object[]", required: false, minItems: 1, maxItems: 5, fields: {
80
+ filename: { kind: "string", required: true, minLength: 1, maxLength: 255 },
81
+ contentType: { kind: "string", required: true, minLength: 1, maxLength: 128 },
82
+ dataBase64: { kind: "string", required: true, minLength: 1, maxLength: 2796203 },
83
+ } },
36
84
  replyToMessageId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9_-]{1,64}$" },
37
85
  },
38
86
  },
87
+ "gmail.attachment.read": {
88
+ idempotencyKey: false,
89
+ fields: {
90
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
91
+ attachmentId: { kind: "string", required: true, minLength: 1, maxLength: 512 },
92
+ },
93
+ },
39
94
  "warehouse.view.read": {
40
95
  idempotencyKey: false,
41
96
  fields: {
@@ -105,6 +160,8 @@ function checkFields(operation, prefix, fields, given, nullIsAbsent) {
105
160
  return refuse(path, `${path} is required for ${operation}`);
106
161
  if ((field.kind === 'integer' || field.kind === 'enum') && field.default !== undefined)
107
162
  out[name] = field.default;
163
+ if (field.kind === 'string[]' && field.default !== undefined)
164
+ out[name] = [...field.default];
108
165
  continue;
109
166
  }
110
167
  const checked = checkField(operation, path, name, field, value);
package/dist/runtime.d.ts CHANGED
@@ -14,6 +14,12 @@ export interface ActionContext {
14
14
  tenant: string;
15
15
  app: string;
16
16
  requestId: string;
17
+ /**
18
+ * Reports a step to a page that invoked the action with `onProgress`; a
19
+ * no-op otherwise. A string or a small JSON object; after 256 steps, or a
20
+ * step over 1 KiB, further calls are dropped.
21
+ */
22
+ progress(step: string | Record<string, unknown>): void;
17
23
  }
18
24
  export interface JobContext {
19
25
  scheduledAt: string;
package/dist/runtime.js CHANGED
@@ -81,6 +81,12 @@ const DEFAULT_MAX_ATTEMPTS = 3;
81
81
  // waits this long for the gateway's data path, probing every retry interval.
82
82
  const DATA_PATH_WAIT_MS = 90_000;
83
83
  const DATA_PATH_RETRY_MS = 3_000;
84
+ // An action's progress stream: asked for with this Accept, one JSON line per
85
+ // ctx.progress call, then the answer as the last line. Bounded so a loop
86
+ // cannot flood the page or crowd the result out of the gateway's 4 MiB cap.
87
+ const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
88
+ const MAX_PROGRESS_EVENTS = 256;
89
+ const MAX_PROGRESS_LINE_BYTES = 1 << 10;
84
90
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
85
91
  export async function discover(root) {
86
92
  const list = async (dir) => {
@@ -147,7 +153,14 @@ function loadHandler(root, dir, name) {
147
153
  return loading;
148
154
  }
149
155
  // ---- HTTP helpers ------------------------------------------------------------
156
+ // Once an action has streamed progress the status line is gone, so its
157
+ // answer — result or refusal — is the stream's last line, the refusal
158
+ // carrying the status it would have had.
150
159
  function answer(response, status, body, requestId) {
160
+ if (response.headersSent) {
161
+ response.end(`${JSON.stringify(status >= 400 ? { ...body, status } : body)}\n`);
162
+ return;
163
+ }
151
164
  const encoded = JSON.stringify(body);
152
165
  response.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(encoded), ...(requestId ? { 'x-request-id': requestId } : {}) });
153
166
  response.end(encoded);
@@ -432,6 +445,7 @@ export class Runtime {
432
445
  }
433
446
  this.running.add(key);
434
447
  try {
448
+ await this.prepareAI();
435
449
  const handler = await loadHandler(this.options.root, JOBS_DIR, name);
436
450
  await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
437
451
  this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
@@ -466,6 +480,7 @@ export class Runtime {
466
480
  return true;
467
481
  }
468
482
  try {
483
+ await this.prepareAI();
469
484
  const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
470
485
  const result = await handler({
471
486
  scheduledAt: now.toISOString(), runId: run.id, input: run.input,
@@ -501,7 +516,29 @@ export class Runtime {
501
516
  email: header(request, 'X-Harbour-Verified-User-Email'),
502
517
  groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
503
518
  };
504
- const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '' };
519
+ const streams = header(request, 'accept').toLowerCase().includes(PROGRESS_MEDIA_TYPE);
520
+ let steps = 0;
521
+ const progress = (step) => {
522
+ if (!streams || steps >= MAX_PROGRESS_EVENTS || response.writableEnded)
523
+ return;
524
+ let encoded;
525
+ try {
526
+ encoded = JSON.stringify(step);
527
+ }
528
+ catch {
529
+ return;
530
+ }
531
+ if (encoded === undefined)
532
+ return;
533
+ const line = `{"progress":${encoded}}\n`;
534
+ if (Buffer.byteLength(line) > MAX_PROGRESS_LINE_BYTES)
535
+ return;
536
+ steps += 1;
537
+ if (!response.headersSent)
538
+ response.writeHead(200, { 'content-type': PROGRESS_MEDIA_TYPE, ...(requestId ? { 'x-request-id': requestId } : {}) });
539
+ response.write(line);
540
+ };
541
+ const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '', progress };
505
542
  const raw = await readBody(request);
506
543
  if (raw === undefined)
507
544
  return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",