viasocket-apps 0.3.0 → 0.5.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
@@ -37,6 +37,18 @@ goes in your server's `.env`. The skill is
37
37
  viaSocket and mirrored here; the same text filled with your ids is served at
38
38
  `https://flow.viasocket.com/documentation/skill.md?org=<org_id>&project=<project_id>`.
39
39
 
40
+ Before you ship — with or without an agent — check the code against the live catalog:
41
+
42
+ ```sh
43
+ curl -fsSL https://flow.viasocket.com/viasocket-check.mjs -o viasocket-check.mjs
44
+ node viasocket-check.mjs src
45
+ ```
46
+
47
+ It finds every viaSocket id and URL in your code and says whether each names a real app, action or
48
+ trigger, is used where its call expects that kind of id (the two ids of an action look alike), and
49
+ whether any viasocket URL was built by hand. No dependencies; exits 1 on a failure, so it can be a
50
+ test. It reads no `.env` file and prints no line of code.
51
+
40
52
  ## Server
41
53
 
42
54
  ```js
@@ -94,8 +106,49 @@ const { authId } = await connect({ embedToken, serviceId: 'rowbu58rc', actions:
94
106
  // Send authId to your backend, which enables the app and stores the script_id.
95
107
  ```
96
108
 
97
- `connect()` rejects with a `ViaSocketConnectError` whose `code` is `'closed'` (the user gave up),
98
- `'rejected'` (the app said no) or `'script'` (the connect script did not load).
109
+ `serviceId` is the app's `service_id` exactly as the catalog returns it — a bare id such as
110
+ `rowbu58rc`, never the app's name or slug, never the id with a name joined on. `connect()` loads
111
+ viaSocket's connect script from `https://flow.viasocket.com/connect.js` and lets it open the popup;
112
+ that script is the only supported way to open it, so never build a viasocket URL for it by hand.
113
+
114
+ `connect()` rejects with a `ViaSocketConnectError` whose `code` is `'closed'` (the user seems to
115
+ have given up), `'rejected'` (the app or viaSocket said no), `'invalid'` (`serviceId` is not a
116
+ service id) or `'script'` (the connect script did not load). `'closed'` is best-effort: once the
117
+ popup is on the app's own sign-in page a close can only be inferred, and arrives about a minute
118
+ later; if the user then finishes anyway, `onLateSuccess` is called with the same result a resolved
119
+ `connect()` gives. Treat `'closed'` as "nothing yet", not as a verdict.
120
+
121
+ ## AI assistant
122
+
123
+ A chat assistant we run for each of your users: threads and history kept per user, your prompt per
124
+ call, your own endpoints as its tools — one of which can run an action in the user's apps. Backend
125
+ only, like everything else here: the prompt and the tools' headers are yours.
126
+
127
+ ```js
128
+ const reply = await user.ai.send({
129
+ threadId: 'support-7f3a9c', // your own string per conversation; omit to start one
130
+ message: 'Where is the order for customer 4471?',
131
+ prompt: 'You are an order assistant. Use lookup_order to fetch orders. Answer in one sentence.',
132
+ tools: [
133
+ {
134
+ name: 'lookup_order',
135
+ description: 'Fetches an order by customer id',
136
+ url: 'https://api.your-product.com/orders/lookup',
137
+ headers: { Authorization: `Bearer ${process.env.ORDERS_API_KEY}` },
138
+ fields: { id: { type: 'string', description: 'Customer id to look up' } },
139
+ requiredParams: ['id']
140
+ }
141
+ ]
142
+ })
143
+ reply.content // the answer; with responseType 'json_object' or 'json_schema', the parsed object
144
+
145
+ const threads = await user.ai.threads() // [{ threadId, title, updatedAt }] — title is null until the AI writes it
146
+ const page = await user.ai.history(threadId, { page: 1 }) // { messages, page, hasMore } — 40 exchanges a page, page 1 the latest
147
+ const socket = await user.ai.rtlayerToken() // for a chat UI: the browser subscribes with it, then you send with delivery: 'rtlayer'
148
+ ```
149
+
150
+ The whole contract — tools, JSON answers, the websocket path and what arrives on it — is
151
+ [flow.viasocket.com/documentation/ai.md](https://flow.viasocket.com/documentation/ai.md).
99
152
 
100
153
  ## Methods
101
154
 
@@ -151,6 +204,9 @@ minutes between checks, as a string: `"5"` or `"15"`.
151
204
  | `user.disableFlow(scriptId)` / `user.enableFlow(scriptId)` | Turn a flow off or back on. Disabling a subscription ends it. |
152
205
  | `user.listConnections()` | Every app this user has connected. |
153
206
  | `user.revokeConnection(authId)` | Disconnect an app. Disable its flows first. |
207
+ | `user.ai.send({ message, threadId, prompt, tools, responseType, delivery })` | One message to the assistant we run for this user. Returns `{ threadId, messageId, content, finishReason }`, or `{ threadId, messageId, channel }` with `delivery: 'rtlayer'`. |
208
+ | `user.ai.threads()` / `user.ai.history(threadId, { page })` | This user's threads, newest first (`title` is `null` until the AI writes it); one thread's messages, 40 exchanges a page. |
209
+ | `user.ai.rtlayerToken()` | A 48-hour subscribe token for the browser, with the `channelPrefix` every thread's channel starts with. |
154
210
  | `signEmbedToken({ orgId, projectId, uniqueIdentifier, secret })` | The signing step on its own, if you want it without a client. |
155
211
 
156
212
  Every failure — a non-2xx status or a body with `success: false` — throws a `ViaSocketError`
@@ -169,7 +225,9 @@ carrying the API's own `message`, the HTTP `status`, and the parsed `body`.
169
225
 
170
226
  Defaults target production: `https://flow-api.viasocket.com` for the API and
171
227
  `https://flow.sokt.io` for running actions. Pass `apiBaseUrl` / `runBaseUrl` to point elsewhere,
172
- and `scriptUrl` to `connect()` for a non-production connect script.
228
+ and `scriptUrl` to `connect()` for a non-production connect script — the script picks its own API
229
+ and auth hosts from the origin it is served from, so that is that stack's copy
230
+ (`https://dev-flow.viasocket.com/connect.js`).
173
231
 
174
232
  ## License
175
233
 
@@ -8,13 +8,23 @@
8
8
  * It wraps viaSocket's standalone connect script — the popup, the postMessage handshake, the
9
9
  * three message types — behind one promise. Nothing here touches the signing secret: the
10
10
  * embedToken comes from your backend, already signed for this end user.
11
+ *
12
+ * The script is served by viaSocket at a fixed URL and is the only supported way to open the
13
+ * popup: it registers the token, hands the session over, opens the right page for its
14
+ * environment, and listens for the result on both paths it can arrive by (the popup's own
15
+ * postMessage, and a relay for sign-in pages that sever the popup's link to this page). A
16
+ * viasocket URL built by hand opens a page that does not exist.
11
17
  */
12
18
  Object.defineProperty(exports, "__esModule", { value: true });
13
19
  exports.ViaSocketConnectError = void 0;
14
20
  exports.loadConnectScript = loadConnectScript;
15
21
  exports.connect = connect;
16
- const DEFAULT_SCRIPT_URL = 'https://embed.viasocket.com/prod-connectcomponent.js';
22
+ const DEFAULT_SCRIPT_URL = 'https://flow.viasocket.com/connect.js';
17
23
  const SCRIPT_ID = 'viasocket-connect-script';
24
+ /** A service id is one bare token of letters and digits: `rowo0bqrhj5g`, never `rowo0bqrhj5g_gmail`. */
25
+ const SERVICE_ID = /^[A-Za-z0-9]+$/;
26
+ /** How long a late success is still delivered after `closed` — the script's own bound. */
27
+ const LATE_SUCCESS_WINDOW_MS = 15 * 60 * 1000;
18
28
  class ViaSocketConnectError extends Error {
19
29
  code;
20
30
  serviceId;
@@ -58,11 +68,13 @@ function loadConnectScript(scriptUrl = DEFAULT_SCRIPT_URL) {
58
68
  });
59
69
  return scriptPromise;
60
70
  }
71
+ const RESULT_TYPES = ['viasocket_connection_success', 'viasocket_connection_error', 'viasocket_connection_closed'];
61
72
  /**
62
73
  * Opens the consent popup for one app and resolves with the connection it creates.
63
74
  *
64
- * Rejects with a ViaSocketConnectError whose `code` says why: 'closed' if the user gave up,
65
- * 'rejected' if the app said no, 'script' if the connect script could not load.
75
+ * Rejects with a ViaSocketConnectError whose `code` says why: 'closed' if the user seems to have
76
+ * given up (best-effort — see `onLateSuccess`), 'rejected' if the app or viaSocket said no,
77
+ * 'invalid' if the serviceId is not a service id, 'script' if the connect script could not load.
66
78
  */
67
79
  async function connect(options) {
68
80
  const serviceId = options?.serviceId;
@@ -70,36 +82,62 @@ async function connect(options) {
70
82
  throw new ViaSocketConnectError('unavailable', serviceId ?? '', 'connect: embedToken is required');
71
83
  if (!serviceId)
72
84
  throw new ViaSocketConnectError('unavailable', '', 'connect: serviceId is required');
85
+ if (typeof serviceId !== 'string' || !SERVICE_ID.test(serviceId)) {
86
+ throw new ViaSocketConnectError('invalid', String(serviceId), `connect: "${serviceId}" is not a service id. Pass the service_id exactly as the catalog returns it (a bare id such as rowo0bqrhj5g), not the app's name or slug, and not the id with a name joined on`);
87
+ }
73
88
  await loadConnectScript(options.scriptUrl);
74
89
  const open = window.openViasocketConnection;
75
90
  if (typeof open !== 'function') {
76
91
  throw new ViaSocketConnectError('script', serviceId, 'The connect script loaded but did not expose openViasocketConnection()');
77
92
  }
78
93
  return new Promise((resolve, reject) => {
79
- const onMessage = (event) => {
94
+ let closedReported = false;
95
+ let lateTimer = null;
96
+ const cleanup = () => {
97
+ window.removeEventListener('message', onMessage);
98
+ if (lateTimer)
99
+ clearTimeout(lateTimer);
100
+ };
101
+ const toResult = (payload) => {
102
+ const connection = (payload.data ?? {});
103
+ const authId = typeof connection.id === 'string' ? connection.id : '';
104
+ return authId ? { authId, serviceId, connection } : null;
105
+ };
106
+ function onMessage(event) {
80
107
  const payload = event?.data;
81
- if (!payload || typeof payload.type !== 'string' || !payload.type.startsWith('viasocket_connection'))
108
+ if (!payload || typeof payload.type !== 'string' || !RESULT_TYPES.includes(payload.type))
82
109
  return;
83
110
  // Two apps can be connecting in one page; only this app's messages are ours.
84
111
  if (payload.serviceId && payload.serviceId !== serviceId)
85
112
  return;
86
- window.removeEventListener('message', onMessage);
113
+ if (payload.type === 'viasocket_connection_closed') {
114
+ if (closedReported)
115
+ return;
116
+ closedReported = true;
117
+ // Keep listening: a close on the app's sign-in page is inferred, and the script still
118
+ // delivers a success that arrives after it.
119
+ lateTimer = setTimeout(cleanup, LATE_SUCCESS_WINDOW_MS);
120
+ reject(new ViaSocketConnectError('closed', serviceId, 'The popup seems to have been closed before the connection finished'));
121
+ return;
122
+ }
123
+ cleanup();
87
124
  if (payload.type === 'viasocket_connection_success') {
88
- const connection = (payload.data ?? {});
89
- const authId = typeof connection.id === 'string' ? connection.id : '';
90
- if (!authId) {
125
+ const result = toResult(payload);
126
+ if (closedReported) {
127
+ if (result)
128
+ options.onLateSuccess?.(result);
129
+ return;
130
+ }
131
+ if (!result) {
91
132
  reject(new ViaSocketConnectError('rejected', serviceId, 'The connection succeeded but carried no id'));
92
133
  return;
93
134
  }
94
- resolve({ authId, serviceId, connection });
135
+ resolve(result);
95
136
  }
96
- else if (payload.type === 'viasocket_connection_error') {
137
+ else if (!closedReported) {
97
138
  reject(new ViaSocketConnectError('rejected', serviceId, payload.error?.message || 'The app rejected the connection'));
98
139
  }
99
- else if (payload.type === 'viasocket_connection_closed') {
100
- reject(new ViaSocketConnectError('closed', serviceId, 'The popup was closed before the connection finished'));
101
- }
102
- };
140
+ }
103
141
  window.addEventListener('message', onMessage);
104
142
  const scriptOptions = {};
105
143
  if (options.actions?.length)
@@ -14,6 +14,9 @@ const errors_js_1 = require("./errors.js");
14
14
  const http_js_1 = require("./http.js");
15
15
  const token_js_1 = require("./token.js");
16
16
  const DEFAULT_API_BASE_URL = 'https://flow-api.viasocket.com';
17
+ /** The AI API's thread id: the product's own string. */
18
+ const AI_THREAD_ID = /^[A-Za-z0-9_-]{1,100}$/;
19
+ const AI_MAX_TOOLS = 20;
17
20
  const DEFAULT_RUN_BASE_URL = 'https://flow.sokt.io';
18
21
  /** The public catalog: no token, the same data in every environment. */
19
22
  const CATALOG = {
@@ -285,6 +288,94 @@ class UserScope {
285
288
  enableFlow(scriptId) {
286
289
  return this.setFlowStatus(scriptId, 1);
287
290
  }
291
+ /**
292
+ * The AI API: a chat assistant viaSocket runs for this user — threads and history kept per
293
+ * `unique_identifier`, your prompt per call, your own endpoints as its tools. Backend only, like
294
+ * everything else here: the prompt and the tools' headers are yours to keep off the browser.
295
+ * The whole contract is https://flow.viasocket.com/documentation/ai.md.
296
+ */
297
+ ai = {
298
+ /**
299
+ * Sends one message and returns the answer — or, with `delivery: 'rtlayer'`, the 202's
300
+ * `channel` the answer will be published on (subscribe to it in the browser before sending).
301
+ * Refuses, before any request, what the API would refuse: a threadId with other characters,
302
+ * `rtlayer` without a threadId, a model without its service, a jsonSchema without its responseType.
303
+ */
304
+ send: (async (params) => {
305
+ const body = { message: required('message', params?.message) };
306
+ if (params.threadId !== undefined) {
307
+ if (!AI_THREAD_ID.test(params.threadId)) {
308
+ throw new errors_js_1.ViaSocketError('ai.send: threadId is letters, digits, - and _, up to 100 characters', { status: null });
309
+ }
310
+ body.thread_id = params.threadId;
311
+ }
312
+ if (params.delivery === 'rtlayer' && !params.threadId) {
313
+ throw new errors_js_1.ViaSocketError('ai.send: threadId is required with delivery "rtlayer" — subscribe to its channel, then send', { status: null });
314
+ }
315
+ if (params.prompt !== undefined)
316
+ body.prompt = params.prompt;
317
+ if (params.responseType)
318
+ body.response_type = params.responseType;
319
+ if (params.jsonSchema) {
320
+ if (params.responseType !== 'json_schema') {
321
+ throw new errors_js_1.ViaSocketError('ai.send: jsonSchema is only allowed with responseType "json_schema"', { status: null });
322
+ }
323
+ body.json_schema = params.jsonSchema;
324
+ }
325
+ if (!params.model !== !params.service)
326
+ throw new errors_js_1.ViaSocketError('ai.send: model and service go together', { status: null });
327
+ if (params.model) {
328
+ body.model = params.model;
329
+ body.service = params.service;
330
+ }
331
+ if (params.tools?.length) {
332
+ if (params.tools.length > AI_MAX_TOOLS)
333
+ throw new errors_js_1.ViaSocketError(`ai.send: at most ${AI_MAX_TOOLS} tools in one call`, { status: null });
334
+ body.extra_tools = params.tools.map((tool) => ({
335
+ name: required('tool.name', tool?.name),
336
+ description: required('tool.description', tool?.description),
337
+ url: required('tool.url', tool?.url),
338
+ method: tool.method ?? 'POST',
339
+ headers: tool.headers ?? {},
340
+ fields: tool.fields ?? {},
341
+ required_params: tool.requiredParams ?? [],
342
+ tool_and_variable_path: tool.toolAndVariablePath ?? {}
343
+ }));
344
+ }
345
+ if (params.delivery)
346
+ body.delivery = params.delivery;
347
+ const { data } = await this.call('POST', '/embed/ai/message', body);
348
+ if (params.delivery === 'rtlayer')
349
+ return { threadId: data.thread_id, messageId: data.message_id, channel: data.channel ?? '' };
350
+ return { threadId: data.thread_id, messageId: data.message_id, content: data.content ?? '', finishReason: data.finish_reason ?? '' };
351
+ }),
352
+ /** This user's threads in this project, most recently active first. */
353
+ threads: async () => {
354
+ const { data } = await this.call('GET', '/embed/ai/threads');
355
+ return (data?.threads ?? []).map((thread) => ({ threadId: thread.thread_id, title: thread.title ?? null, updatedAt: thread.updated_at }));
356
+ },
357
+ /** One thread's messages, 40 exchanges a page; page 1 holds the latest, oldest of those first. */
358
+ history: async (threadId, params = {}) => {
359
+ const page = Math.max(1, Math.floor(params.page ?? 1));
360
+ const { data } = await this.call('GET', `/embed/ai/threads/${encodeURIComponent(required('threadId', threadId))}/history?page=${page}`);
361
+ return {
362
+ threadId: data.thread_id ?? threadId,
363
+ messages: (data.messages ?? []).map((message) => ({
364
+ role: message.role,
365
+ content: message.content,
366
+ messageId: message.message_id,
367
+ createdAt: message.created_at
368
+ })),
369
+ page: data.page ?? page,
370
+ hasMore: Boolean(data.has_more)
371
+ };
372
+ },
373
+ /** A 48-hour subscribe token for the browser, and the prefix every thread's channel starts with. */
374
+ rtlayerToken: async () => {
375
+ const { data } = await this.call('GET', '/embed/ai/rtlayer-token');
376
+ return { token: data.token, orgId: data.org_id, serviceId: data.service_id, channelPrefix: data.channel_prefix, expiresIn: data.expires_in };
377
+ }
378
+ };
288
379
  /**
289
380
  * Every app this user has connected. Returned as the API sends it: the shape of each entry
290
381
  * is not yet documented, so nothing is mapped or dropped here.
package/dist/cjs/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * const { options } = await user.listOptions(actionVersionId, {...}) // fill a dropdown
13
13
  * const result = await viasocket.runAction(scriptId, actionVersionId, inputData)
14
14
  * const sub = await user.subscribe(triggerVersionId, { authId, inputData, code: handler })
15
+ * const reply = await user.ai.send({ threadId, message, prompt, tools }) // a chat assistant we run for this user
15
16
  *
16
17
  * The browser half — opening the connect popup — lives at 'viasocket-apps/browser'.
17
18
  */
@@ -7,14 +7,26 @@
7
7
  * It wraps viaSocket's standalone connect script — the popup, the postMessage handshake, the
8
8
  * three message types — behind one promise. Nothing here touches the signing secret: the
9
9
  * embedToken comes from your backend, already signed for this end user.
10
+ *
11
+ * The script is served by viaSocket at a fixed URL and is the only supported way to open the
12
+ * popup: it registers the token, hands the session over, opens the right page for its
13
+ * environment, and listens for the result on both paths it can arrive by (the popup's own
14
+ * postMessage, and a relay for sign-in pages that sever the popup's link to this page). A
15
+ * viasocket URL built by hand opens a page that does not exist.
10
16
  */
11
17
  export type ConnectErrorCode =
12
- /** The user closed the popup before finishing. Nothing was created. */
18
+ /**
19
+ * The popup seems to have been closed before finishing. Best-effort: once the popup is on the
20
+ * app's own sign-in page a close can only be inferred, and arrives about a minute later. If the
21
+ * user in fact finishes after that, `onLateSuccess` is called with the connection.
22
+ */
13
23
  'closed'
14
- /** The app refused the authorisation. */
24
+ /** The app refused the authorisation, or viaSocket refused the request (an id that names no app). */
15
25
  | 'rejected'
16
26
  /** The connect script could not be loaded. */
17
27
  | 'script'
28
+ /** The `serviceId` is not a bare service id — the app's name, its slug, or an id with a name joined on. */
29
+ | 'invalid'
18
30
  /** Not running in a browser, or the popup could not be opened. */
19
31
  | 'unavailable';
20
32
  export declare class ViaSocketConnectError extends Error {
@@ -25,7 +37,10 @@ export declare class ViaSocketConnectError extends Error {
25
37
  export interface ConnectOptions {
26
38
  /** Signed on your backend for the current end user — see `viasocket.user(id).token()`. */
27
39
  embedToken: string;
28
- /** The app to connect. Its service_id is shown on every app in the Apps API reference. */
40
+ /**
41
+ * The app to connect: its `service_id` exactly as the catalog returns it (`rowo0bqrhj5g`).
42
+ * Never the app's name or slug, never the id with a name joined on.
43
+ */
29
44
  serviceId: string;
30
45
  /**
31
46
  * The `action_id`s and `trigger_id`s this product uses (listed under each one in the app's
@@ -35,7 +50,17 @@ export interface ConnectOptions {
35
50
  actions?: string[];
36
51
  /** With `actions`: skip the action list and open the app's consent screen directly. */
37
52
  skipActionSelection?: boolean;
38
- /** Where the connect script is served from. Only override for a non-production environment. */
53
+ /**
54
+ * Called if the connection completes after `connect()` already rejected with `'closed'` — the
55
+ * close was inferred while the user was still on the app's sign-in page. Treat it exactly like
56
+ * a resolved `connect()`.
57
+ */
58
+ onLateSuccess?: (result: ConnectResult) => void;
59
+ /**
60
+ * Where the connect script is served from. The script picks its API and auth hosts from its
61
+ * own origin, so for a non-production stack point this at that stack's copy
62
+ * (`https://dev-flow.viasocket.com/connect.js`).
63
+ */
39
64
  scriptUrl?: string;
40
65
  }
41
66
  /** The third argument of the connect script's `openViasocketConnection`. */
@@ -52,7 +77,7 @@ export interface ConnectResult {
52
77
  }
53
78
  declare global {
54
79
  interface Window {
55
- openViasocketConnection?: (embedToken: string, serviceId: string, options?: ConnectScriptOptions) => void;
80
+ openViasocketConnection?: (embedToken: string, serviceId: string, options?: ConnectScriptOptions) => Promise<unknown> | void;
56
81
  }
57
82
  }
58
83
  /**
@@ -63,8 +88,9 @@ export declare function loadConnectScript(scriptUrl?: string): Promise<void>;
63
88
  /**
64
89
  * Opens the consent popup for one app and resolves with the connection it creates.
65
90
  *
66
- * Rejects with a ViaSocketConnectError whose `code` says why: 'closed' if the user gave up,
67
- * 'rejected' if the app said no, 'script' if the connect script could not load.
91
+ * Rejects with a ViaSocketConnectError whose `code` says why: 'closed' if the user seems to have
92
+ * given up (best-effort — see `onLateSuccess`), 'rejected' if the app or viaSocket said no,
93
+ * 'invalid' if the serviceId is not a service id, 'script' if the connect script could not load.
68
94
  */
69
95
  export declare function connect(options: ConnectOptions): Promise<ConnectResult>;
70
96
  export {};
@@ -7,9 +7,19 @@
7
7
  * It wraps viaSocket's standalone connect script — the popup, the postMessage handshake, the
8
8
  * three message types — behind one promise. Nothing here touches the signing secret: the
9
9
  * embedToken comes from your backend, already signed for this end user.
10
+ *
11
+ * The script is served by viaSocket at a fixed URL and is the only supported way to open the
12
+ * popup: it registers the token, hands the session over, opens the right page for its
13
+ * environment, and listens for the result on both paths it can arrive by (the popup's own
14
+ * postMessage, and a relay for sign-in pages that sever the popup's link to this page). A
15
+ * viasocket URL built by hand opens a page that does not exist.
10
16
  */
11
- const DEFAULT_SCRIPT_URL = 'https://embed.viasocket.com/prod-connectcomponent.js';
17
+ const DEFAULT_SCRIPT_URL = 'https://flow.viasocket.com/connect.js';
12
18
  const SCRIPT_ID = 'viasocket-connect-script';
19
+ /** A service id is one bare token of letters and digits: `rowo0bqrhj5g`, never `rowo0bqrhj5g_gmail`. */
20
+ const SERVICE_ID = /^[A-Za-z0-9]+$/;
21
+ /** How long a late success is still delivered after `closed` — the script's own bound. */
22
+ const LATE_SUCCESS_WINDOW_MS = 15 * 60 * 1000;
13
23
  export class ViaSocketConnectError extends Error {
14
24
  code;
15
25
  serviceId;
@@ -52,11 +62,13 @@ export function loadConnectScript(scriptUrl = DEFAULT_SCRIPT_URL) {
52
62
  });
53
63
  return scriptPromise;
54
64
  }
65
+ const RESULT_TYPES = ['viasocket_connection_success', 'viasocket_connection_error', 'viasocket_connection_closed'];
55
66
  /**
56
67
  * Opens the consent popup for one app and resolves with the connection it creates.
57
68
  *
58
- * Rejects with a ViaSocketConnectError whose `code` says why: 'closed' if the user gave up,
59
- * 'rejected' if the app said no, 'script' if the connect script could not load.
69
+ * Rejects with a ViaSocketConnectError whose `code` says why: 'closed' if the user seems to have
70
+ * given up (best-effort — see `onLateSuccess`), 'rejected' if the app or viaSocket said no,
71
+ * 'invalid' if the serviceId is not a service id, 'script' if the connect script could not load.
60
72
  */
61
73
  export async function connect(options) {
62
74
  const serviceId = options?.serviceId;
@@ -64,36 +76,62 @@ export async function connect(options) {
64
76
  throw new ViaSocketConnectError('unavailable', serviceId ?? '', 'connect: embedToken is required');
65
77
  if (!serviceId)
66
78
  throw new ViaSocketConnectError('unavailable', '', 'connect: serviceId is required');
79
+ if (typeof serviceId !== 'string' || !SERVICE_ID.test(serviceId)) {
80
+ throw new ViaSocketConnectError('invalid', String(serviceId), `connect: "${serviceId}" is not a service id. Pass the service_id exactly as the catalog returns it (a bare id such as rowo0bqrhj5g), not the app's name or slug, and not the id with a name joined on`);
81
+ }
67
82
  await loadConnectScript(options.scriptUrl);
68
83
  const open = window.openViasocketConnection;
69
84
  if (typeof open !== 'function') {
70
85
  throw new ViaSocketConnectError('script', serviceId, 'The connect script loaded but did not expose openViasocketConnection()');
71
86
  }
72
87
  return new Promise((resolve, reject) => {
73
- const onMessage = (event) => {
88
+ let closedReported = false;
89
+ let lateTimer = null;
90
+ const cleanup = () => {
91
+ window.removeEventListener('message', onMessage);
92
+ if (lateTimer)
93
+ clearTimeout(lateTimer);
94
+ };
95
+ const toResult = (payload) => {
96
+ const connection = (payload.data ?? {});
97
+ const authId = typeof connection.id === 'string' ? connection.id : '';
98
+ return authId ? { authId, serviceId, connection } : null;
99
+ };
100
+ function onMessage(event) {
74
101
  const payload = event?.data;
75
- if (!payload || typeof payload.type !== 'string' || !payload.type.startsWith('viasocket_connection'))
102
+ if (!payload || typeof payload.type !== 'string' || !RESULT_TYPES.includes(payload.type))
76
103
  return;
77
104
  // Two apps can be connecting in one page; only this app's messages are ours.
78
105
  if (payload.serviceId && payload.serviceId !== serviceId)
79
106
  return;
80
- window.removeEventListener('message', onMessage);
107
+ if (payload.type === 'viasocket_connection_closed') {
108
+ if (closedReported)
109
+ return;
110
+ closedReported = true;
111
+ // Keep listening: a close on the app's sign-in page is inferred, and the script still
112
+ // delivers a success that arrives after it.
113
+ lateTimer = setTimeout(cleanup, LATE_SUCCESS_WINDOW_MS);
114
+ reject(new ViaSocketConnectError('closed', serviceId, 'The popup seems to have been closed before the connection finished'));
115
+ return;
116
+ }
117
+ cleanup();
81
118
  if (payload.type === 'viasocket_connection_success') {
82
- const connection = (payload.data ?? {});
83
- const authId = typeof connection.id === 'string' ? connection.id : '';
84
- if (!authId) {
119
+ const result = toResult(payload);
120
+ if (closedReported) {
121
+ if (result)
122
+ options.onLateSuccess?.(result);
123
+ return;
124
+ }
125
+ if (!result) {
85
126
  reject(new ViaSocketConnectError('rejected', serviceId, 'The connection succeeded but carried no id'));
86
127
  return;
87
128
  }
88
- resolve({ authId, serviceId, connection });
129
+ resolve(result);
89
130
  }
90
- else if (payload.type === 'viasocket_connection_error') {
131
+ else if (!closedReported) {
91
132
  reject(new ViaSocketConnectError('rejected', serviceId, payload.error?.message || 'The app rejected the connection'));
92
133
  }
93
- else if (payload.type === 'viasocket_connection_closed') {
94
- reject(new ViaSocketConnectError('closed', serviceId, 'The popup was closed before the connection finished'));
95
- }
96
- };
134
+ }
97
135
  window.addEventListener('message', onMessage);
98
136
  const scriptOptions = {};
99
137
  if (options.actions?.length)
@@ -7,7 +7,7 @@
7
7
  * everything else behind `user(id)` makes that distinction structural rather than something a
8
8
  * reader has to remember.
9
9
  */
10
- import type { CatalogApp, CatalogVersions, EnableResult, Flow, FlowStatus, ListOptionsParams, ListAppsParams, ListOptionsResult, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
10
+ import type { AiHistoryPage, AiRtLayerToken, AiSend, AiThread, CatalogApp, CatalogVersions, EnableResult, Flow, FlowStatus, ListOptionsParams, ListAppsParams, ListOptionsResult, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
11
11
  export declare class ViaSocket {
12
12
  readonly orgId: string;
13
13
  readonly projectId: string;
@@ -108,6 +108,29 @@ export declare class UserScope {
108
108
  id: string;
109
109
  status: string;
110
110
  }>;
111
+ /**
112
+ * The AI API: a chat assistant viaSocket runs for this user — threads and history kept per
113
+ * `unique_identifier`, your prompt per call, your own endpoints as its tools. Backend only, like
114
+ * everything else here: the prompt and the tools' headers are yours to keep off the browser.
115
+ * The whole contract is https://flow.viasocket.com/documentation/ai.md.
116
+ */
117
+ readonly ai: {
118
+ /**
119
+ * Sends one message and returns the answer — or, with `delivery: 'rtlayer'`, the 202's
120
+ * `channel` the answer will be published on (subscribe to it in the browser before sending).
121
+ * Refuses, before any request, what the API would refuse: a threadId with other characters,
122
+ * `rtlayer` without a threadId, a model without its service, a jsonSchema without its responseType.
123
+ */
124
+ send: AiSend;
125
+ /** This user's threads in this project, most recently active first. */
126
+ threads: () => Promise<AiThread[]>;
127
+ /** One thread's messages, 40 exchanges a page; page 1 holds the latest, oldest of those first. */
128
+ history: (threadId: string, params?: {
129
+ page?: number;
130
+ }) => Promise<AiHistoryPage>;
131
+ /** A 48-hour subscribe token for the browser, and the prefix every thread's channel starts with. */
132
+ rtlayerToken: () => Promise<AiRtLayerToken>;
133
+ };
111
134
  /**
112
135
  * Every app this user has connected. Returned as the API sends it: the shape of each entry
113
136
  * is not yet documented, so nothing is mapped or dropped here.
@@ -11,6 +11,9 @@ import { ViaSocketError } from './errors.js';
11
11
  import { request } from './http.js';
12
12
  import { signEmbedToken } from './token.js';
13
13
  const DEFAULT_API_BASE_URL = 'https://flow-api.viasocket.com';
14
+ /** The AI API's thread id: the product's own string. */
15
+ const AI_THREAD_ID = /^[A-Za-z0-9_-]{1,100}$/;
16
+ const AI_MAX_TOOLS = 20;
14
17
  const DEFAULT_RUN_BASE_URL = 'https://flow.sokt.io';
15
18
  /** The public catalog: no token, the same data in every environment. */
16
19
  const CATALOG = {
@@ -281,6 +284,94 @@ export class UserScope {
281
284
  enableFlow(scriptId) {
282
285
  return this.setFlowStatus(scriptId, 1);
283
286
  }
287
+ /**
288
+ * The AI API: a chat assistant viaSocket runs for this user — threads and history kept per
289
+ * `unique_identifier`, your prompt per call, your own endpoints as its tools. Backend only, like
290
+ * everything else here: the prompt and the tools' headers are yours to keep off the browser.
291
+ * The whole contract is https://flow.viasocket.com/documentation/ai.md.
292
+ */
293
+ ai = {
294
+ /**
295
+ * Sends one message and returns the answer — or, with `delivery: 'rtlayer'`, the 202's
296
+ * `channel` the answer will be published on (subscribe to it in the browser before sending).
297
+ * Refuses, before any request, what the API would refuse: a threadId with other characters,
298
+ * `rtlayer` without a threadId, a model without its service, a jsonSchema without its responseType.
299
+ */
300
+ send: (async (params) => {
301
+ const body = { message: required('message', params?.message) };
302
+ if (params.threadId !== undefined) {
303
+ if (!AI_THREAD_ID.test(params.threadId)) {
304
+ throw new ViaSocketError('ai.send: threadId is letters, digits, - and _, up to 100 characters', { status: null });
305
+ }
306
+ body.thread_id = params.threadId;
307
+ }
308
+ if (params.delivery === 'rtlayer' && !params.threadId) {
309
+ throw new ViaSocketError('ai.send: threadId is required with delivery "rtlayer" — subscribe to its channel, then send', { status: null });
310
+ }
311
+ if (params.prompt !== undefined)
312
+ body.prompt = params.prompt;
313
+ if (params.responseType)
314
+ body.response_type = params.responseType;
315
+ if (params.jsonSchema) {
316
+ if (params.responseType !== 'json_schema') {
317
+ throw new ViaSocketError('ai.send: jsonSchema is only allowed with responseType "json_schema"', { status: null });
318
+ }
319
+ body.json_schema = params.jsonSchema;
320
+ }
321
+ if (!params.model !== !params.service)
322
+ throw new ViaSocketError('ai.send: model and service go together', { status: null });
323
+ if (params.model) {
324
+ body.model = params.model;
325
+ body.service = params.service;
326
+ }
327
+ if (params.tools?.length) {
328
+ if (params.tools.length > AI_MAX_TOOLS)
329
+ throw new ViaSocketError(`ai.send: at most ${AI_MAX_TOOLS} tools in one call`, { status: null });
330
+ body.extra_tools = params.tools.map((tool) => ({
331
+ name: required('tool.name', tool?.name),
332
+ description: required('tool.description', tool?.description),
333
+ url: required('tool.url', tool?.url),
334
+ method: tool.method ?? 'POST',
335
+ headers: tool.headers ?? {},
336
+ fields: tool.fields ?? {},
337
+ required_params: tool.requiredParams ?? [],
338
+ tool_and_variable_path: tool.toolAndVariablePath ?? {}
339
+ }));
340
+ }
341
+ if (params.delivery)
342
+ body.delivery = params.delivery;
343
+ const { data } = await this.call('POST', '/embed/ai/message', body);
344
+ if (params.delivery === 'rtlayer')
345
+ return { threadId: data.thread_id, messageId: data.message_id, channel: data.channel ?? '' };
346
+ return { threadId: data.thread_id, messageId: data.message_id, content: data.content ?? '', finishReason: data.finish_reason ?? '' };
347
+ }),
348
+ /** This user's threads in this project, most recently active first. */
349
+ threads: async () => {
350
+ const { data } = await this.call('GET', '/embed/ai/threads');
351
+ return (data?.threads ?? []).map((thread) => ({ threadId: thread.thread_id, title: thread.title ?? null, updatedAt: thread.updated_at }));
352
+ },
353
+ /** One thread's messages, 40 exchanges a page; page 1 holds the latest, oldest of those first. */
354
+ history: async (threadId, params = {}) => {
355
+ const page = Math.max(1, Math.floor(params.page ?? 1));
356
+ const { data } = await this.call('GET', `/embed/ai/threads/${encodeURIComponent(required('threadId', threadId))}/history?page=${page}`);
357
+ return {
358
+ threadId: data.thread_id ?? threadId,
359
+ messages: (data.messages ?? []).map((message) => ({
360
+ role: message.role,
361
+ content: message.content,
362
+ messageId: message.message_id,
363
+ createdAt: message.created_at
364
+ })),
365
+ page: data.page ?? page,
366
+ hasMore: Boolean(data.has_more)
367
+ };
368
+ },
369
+ /** A 48-hour subscribe token for the browser, and the prefix every thread's channel starts with. */
370
+ rtlayerToken: async () => {
371
+ const { data } = await this.call('GET', '/embed/ai/rtlayer-token');
372
+ return { token: data.token, orgId: data.org_id, serviceId: data.service_id, channelPrefix: data.channel_prefix, expiresIn: data.expires_in };
373
+ }
374
+ };
284
375
  /**
285
376
  * Every app this user has connected. Returned as the API sends it: the shape of each entry
286
377
  * is not yet documented, so nothing is mapped or dropped here.
@@ -11,6 +11,7 @@
11
11
  * const { options } = await user.listOptions(actionVersionId, {...}) // fill a dropdown
12
12
  * const result = await viasocket.runAction(scriptId, actionVersionId, inputData)
13
13
  * const sub = await user.subscribe(triggerVersionId, { authId, inputData, code: handler })
14
+ * const reply = await user.ai.send({ threadId, message, prompt, tools }) // a chat assistant we run for this user
14
15
  *
15
16
  * The browser half — opening the connect popup — lives at 'viasocket-apps/browser'.
16
17
  */
@@ -18,4 +19,4 @@ export { ViaSocket, UserScope } from './client.js';
18
19
  export { ViaSocketError } from './errors.js';
19
20
  export { signEmbedToken } from './token.js';
20
21
  export type { SignEmbedTokenInput } from './token.js';
21
- export type { CatalogApp, CatalogAction, CatalogService, CatalogTrigger, CatalogVersion, CatalogVersions, EnableResult, Envelope, Flow, FlowStatus, ListAppsParams, ListOptionsParams, ListOptionsResult, Option, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
22
+ export type { AiAccepted, AiHistoryPage, AiMessage, AiReply, AiRtLayerToken, AiSend, AiSendParams, AiThread, AiTool, CatalogApp, CatalogAction, CatalogService, CatalogTrigger, CatalogVersion, CatalogVersions, EnableResult, Envelope, Flow, FlowStatus, ListAppsParams, ListOptionsParams, ListOptionsResult, Option, SubscribeParams, SubscribeResult, UpdateSubscriptionParams, ViaSocketOptions } from './types.js';
package/dist/esm/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * const { options } = await user.listOptions(actionVersionId, {...}) // fill a dropdown
12
12
  * const result = await viasocket.runAction(scriptId, actionVersionId, inputData)
13
13
  * const sub = await user.subscribe(triggerVersionId, { authId, inputData, code: handler })
14
+ * const reply = await user.ai.send({ threadId, message, prompt, tools }) // a chat assistant we run for this user
14
15
  *
15
16
  * The browser half — opening the connect popup — lives at 'viasocket-apps/browser'.
16
17
  */
@@ -132,8 +132,10 @@ interface CatalogEventBase {
132
132
  name: string;
133
133
  description: string;
134
134
  /**
135
- * The field schema a form is rendered from; `inputData` follows it. A block with a `source` or
136
- * `optionsGenerator` key is a picker: fill it with `listOptions`, never by running what the key holds.
135
+ * The field schema a form is rendered from; `inputData` follows it. A `dropdown` / `multiselect`
136
+ * block with no `options` (or with `enableSearchApi` / `willDynamicFetchOptions`), and an
137
+ * `input groups` block with no children in `steps`, is a picker: fill it with `listOptions`. The
138
+ * schema carries no generator code.
137
139
  */
138
140
  input_schema: Record<string, unknown>;
139
141
  /** A real response of the action, or a real event payload of the trigger. */
@@ -177,4 +179,113 @@ export interface Envelope<T = unknown> {
177
179
  data: T;
178
180
  isCached?: boolean;
179
181
  }
182
+ /**
183
+ * The AI API — `user.ai`: a chat assistant viaSocket runs for one end user, with threads and
184
+ * history kept per `unique_identifier` and your own HTTP endpoints as the tools it may call.
185
+ */
186
+ /** One HTTP endpoint of yours the assistant may call while answering. */
187
+ export interface AiTool {
188
+ /** The name the model calls it by, and the name you use in the prompt: letters, digits, underscores. */
189
+ name: string;
190
+ /** When to use it, in a sentence or two. The model decides from this. */
191
+ description: string;
192
+ /** The endpoint to call: your own backend, with its own auth in `headers`. Never a run URL — its script_id is a credential. */
193
+ url: string;
194
+ /** Default POST. */
195
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
196
+ /** Headers for that call — your own API key. They stay server-side. */
197
+ headers?: Record<string, string>;
198
+ /** The parameters the model may fill, each with a `type` and a `description` it fills from. */
199
+ fields?: Record<string, {
200
+ type: string;
201
+ description?: string;
202
+ [key: string]: unknown;
203
+ }>;
204
+ /** Which of `fields` the model must fill before calling. */
205
+ requiredParams?: string[];
206
+ /** Maps prompt variables to tool parameters. Advanced; omit for an ordinary tool. */
207
+ toolAndVariablePath?: Record<string, unknown>;
208
+ }
209
+ export interface AiSendParams {
210
+ /** What the user said. */
211
+ message: string;
212
+ /**
213
+ * Your own string per conversation: letters, digits, `-` and `_`, up to 100 characters. Omit to
214
+ * start a thread and read the id from the reply. Required with `delivery: 'rtlayer'`, so the
215
+ * browser can subscribe to the thread's channel before the send.
216
+ */
217
+ threadId?: string;
218
+ /**
219
+ * The system prompt for this call. It is not stored, so send it every time. May use
220
+ * `{{orgId}}`, `{{projectId}}` and `{{userId}}`. Name the tools in it and say when to use each.
221
+ */
222
+ prompt?: string;
223
+ /** `text` (default); `json_object`; or `json_schema`, which needs `jsonSchema`. */
224
+ responseType?: 'text' | 'json_object' | 'json_schema';
225
+ jsonSchema?: {
226
+ name: string;
227
+ schema: Record<string, unknown>;
228
+ strict?: boolean;
229
+ };
230
+ /** A model and its provider, always together: `model: 'gpt-5.6-luna', service: 'openai'`. Omit both for the default. */
231
+ model?: string;
232
+ service?: string;
233
+ /** Up to 20. Only the ones this conversation can need. */
234
+ tools?: AiTool[];
235
+ /** `sync` (default): the answer in the response. `rtlayer`: a 202 and the answer on the thread's websocket channel. */
236
+ delivery?: 'sync' | 'rtlayer';
237
+ }
238
+ /** The answer, from a synchronous send. */
239
+ export interface AiReply {
240
+ threadId: string;
241
+ messageId: string;
242
+ /** A string, or the parsed object for a JSON `responseType`. */
243
+ content: string | Record<string, unknown>;
244
+ /** `completed`, `truncated` (out of output tokens), or `tool_call` (stopped to call a tool). */
245
+ finishReason: 'completed' | 'truncated' | 'tool_call' | string;
246
+ }
247
+ /** The acknowledgement of a send with `delivery: 'rtlayer'`; the answer follows on `channel`. */
248
+ export interface AiAccepted {
249
+ threadId: string;
250
+ messageId: string;
251
+ /** `channelPrefix + threadId`, from `rtlayerToken()`. */
252
+ channel: string;
253
+ }
254
+ export interface AiSend {
255
+ (params: AiSendParams & {
256
+ delivery: 'rtlayer';
257
+ }): Promise<AiAccepted>;
258
+ (params: AiSendParams & {
259
+ delivery?: 'sync';
260
+ }): Promise<AiReply>;
261
+ }
262
+ export interface AiThread {
263
+ threadId: string;
264
+ /** Written by the AI from the thread's first message; `null` until then. */
265
+ title: string | null;
266
+ updatedAt: string;
267
+ }
268
+ export interface AiMessage {
269
+ role: 'user' | 'assistant' | string;
270
+ content: string;
271
+ /** A user message and its reply share one id. */
272
+ messageId: string;
273
+ createdAt: string;
274
+ }
275
+ /** 40 exchanges a page; page 1 holds the latest, in chronological order. */
276
+ export interface AiHistoryPage {
277
+ threadId: string;
278
+ messages: AiMessage[];
279
+ page: number;
280
+ hasMore: boolean;
281
+ }
282
+ /** What the browser needs to listen for replies: valid 48 hours, this user's threads only. */
283
+ export interface AiRtLayerToken {
284
+ token: string;
285
+ orgId: string;
286
+ serviceId: string;
287
+ /** A thread's channel is this followed by the threadId. */
288
+ channelPrefix: string;
289
+ expiresIn: string;
290
+ }
180
291
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viasocket-apps",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Server-side client for the viaSocket Apps API: let your users connect Slack, GitHub, Google Sheets or any of 2,300+ apps from your own product, then run actions and receive events through one API.",
5
5
  "keywords": [
6
6
  "viasocket",