@softr/sdk 0.0.91-alpha.1 → 0.0.92-emailotc.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.
Files changed (37) hide show
  1. package/dist/auth/methods/emailCode2fa.d.ts +34 -0
  2. package/dist/auth/methods/emailCode2fa.js +71 -0
  3. package/dist/auth/methods/password.d.ts +4 -1
  4. package/dist/auth/methods/password.js +6 -1
  5. package/dist/auth/platform.js +25 -0
  6. package/dist/auth/server.d.ts +3 -0
  7. package/dist/auth/server.js +4 -1
  8. package/dist/datasource/record.js +3 -12
  9. package/dist/default-error-overlay.d.ts +2 -2
  10. package/dist/formatting.d.ts +3 -3
  11. package/dist/index.d.ts +1 -3
  12. package/dist/index.js +1 -3
  13. package/dist/mcp.d.ts +4 -17
  14. package/dist/mcp.js +18 -61
  15. package/dist/package.js +1 -1
  16. package/package.json +33 -10
  17. package/skills/softr-sdk/SKILL.md +1 -5
  18. package/skills/softr-sdk/ai/SKILL.md +1 -1
  19. package/skills/softr-sdk/auth/SKILL.md +31 -3
  20. package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
  21. package/skills/softr-sdk/current-user/SKILL.md +5 -3
  22. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  23. package/skills/softr-sdk/formatting/SKILL.md +1 -1
  24. package/skills/softr-sdk/integrations/SKILL.md +11 -9
  25. package/skills/softr-sdk/mcp/SKILL.md +1 -16
  26. package/skills/softr-sdk/pwa/SKILL.md +1 -1
  27. package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
  28. package/skills/softr-sdk/record-queries/SKILL.md +1 -1
  29. package/skills/softr-sdk/record-queries/references/filters-and-sorting.md +2 -0
  30. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  31. package/skills/softr-sdk/routes-and-visibility/SKILL.md +6 -4
  32. package/skills/softr-sdk/workflows/SKILL.md +1 -1
  33. package/dist/agent/agent-fns.d.ts +0 -30
  34. package/dist/agent/agent-fns.js +0 -215
  35. package/dist/agent/use-agent-chat.d.ts +0 -106
  36. package/dist/agent/use-agent-chat.js +0 -486
  37. package/skills/softr-sdk/agent-chat/SKILL.md +0 -110
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: core
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  # Softr SDK core
@@ -52,7 +52,6 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
52
52
  | Ask an AI model: a summary, an extraction from a document, a classification, a draft, a chat | [ai/SKILL.md](./ai/SKILL.md) |
53
53
  | Call a connected third-party API (Slack, Stripe, Gmail, …), or an AI model on the user's own provider account | [integrations/SKILL.md](./integrations/SKILL.md) |
54
54
  | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
55
- | Let visitors chat with a Softr agent | [agent-chat/SKILL.md](./agent-chat/SKILL.md) |
56
55
  | Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
57
56
  | Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
58
57
 
@@ -95,9 +94,6 @@ Running a model on the user's OWN provider account, because they chose that for
95
94
  Opening the app to an AI assistant over MCP?
96
95
  load softr-sdk/mcp
97
96
 
98
- Adding a chat with one of the app's Softr agents, or a Connect button for the visitor's account it needs?
99
- load softr-sdk/agent-chat
100
-
101
97
  Adding an install button, or making the app installable?
102
98
  load softr-sdk/pwa
103
99
 
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.79-alpha.0
16
+ library_version: 0.0.91
17
17
  ---
18
18
 
19
19
  **Boundary.** `ai` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineAiAction` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -3,13 +3,13 @@ name: auth
3
3
  description: >-
4
4
  Sign a visitor in, out or up from a server function with auth from
5
5
  @softr/sdk/server: password, code, Google and magic link, sign-up and its
6
- confirmation, the authenticator-app steps, password reset links,
6
+ confirmation, the emailed-code and authenticator-app steps, password reset links,
7
7
  updateProfile, and getAuthSettings and useAuthSettings for the methods the app
8
8
  enables. Covers the Result shape, its error codes, and toReturnPath.
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
@@ -55,6 +55,13 @@ Signing out:
55
55
  | --- | --- |
56
56
  | `auth.signOut()` | Ends every session that visitor has in this app, on every device |
57
57
 
58
+ Finishing a sign-in that wants a code from the visitor's inbox:
59
+
60
+ | Call | Does |
61
+ | --- | --- |
62
+ | `auth.verifyEmailCode({ code })` | Answers it with the code that was emailed |
63
+ | `auth.resendEmailCode()` | Emails that code again |
64
+
58
65
  Finishing a sign-in that wants an authenticator app:
59
66
 
60
67
  | Call | Does |
@@ -197,6 +204,8 @@ Those calls take only what the visitor typed. The sign-in they finish is the one
197
204
 
198
205
  An app with no authenticator app configured never answers `authenticatorRequired`. The type still has both arms either way, so `result.data.user` does not exist until you have narrowed on `status`.
199
206
 
207
+ `signIn` has a third answer, `{ status: 'emailCodeRequired' }`, for an app that asks a password sign-in for a code from the visitor's inbox as well. The code is already on its way. Show a code field as the next step and finish with `auth.verifyEmailCode({ code })`, see [Finishing a sign-in that needs an emailed code](#finishing-a-sign-in-that-needs-an-emailed-code). That call answers like `signIn` does, so an app that also wants an authenticator app goes on to that step after it.
208
+
200
209
  ## Landing after sign-in
201
210
 
202
211
  A guard that turns a signed-out visitor away sends them to the page `softr.json` names for that, usually the login page. The page they wanted rides along in the search param named for the login page. `toReturnPath` from `@softr/sdk` reads it back:
@@ -255,10 +264,13 @@ if (answer.message !== null) return setMessage(answer.message)
255
264
  if (answer.outcome.status === 'authenticatorRequired') {
256
265
  return setStep({ name: 'secondFactor', opensOn: answer.outcome.step })
257
266
  }
267
+ if (answer.outcome.status === 'emailCodeRequired') {
268
+ return setStep({ name: 'emailCode' })
269
+ }
258
270
  await navigate({ href: target })
259
271
  ```
260
272
 
261
- Three exits: a message for the form to render, the second-factor step, the return target.
273
+ Four exits: a message for the form to render, the emailed-code step, the authenticator step, the return target.
262
274
 
263
275
  A server function that throws `redirect` needs the hook. Call the imported function directly and the throw never reaches the router, so a successful sign-in leaves the visitor on the form.
264
276
 
@@ -385,6 +397,22 @@ The UI has to carry email into step two, plus the name if step one collected one
385
397
 
386
398
  `password` is what the account is created with. Leave it out only for an app that signs people in with an emailed code. An app that expects a password rejects a sign-up without one, and an app that does not expect one rejects a sign-up that sends it.
387
399
 
400
+ ### Finishing a sign-in that needs an emailed code
401
+
402
+ After `signIn` answers `emailCodeRequired`, the visitor owes the code that was just emailed to them:
403
+
404
+ ```ts
405
+ // invalidCode for a wrong or expired code, otherwise the same two-armed result as signIn.
406
+ const result = await auth.verifyEmailCode({ code })
407
+
408
+ // For a "Resend code" link.
409
+ const sent = await auth.resendEmailCode()
410
+ ```
411
+
412
+ Send `authenticatorRequired` on to the authenticator step, never into the app.
413
+
414
+ Asking for a resend too soon sends nothing and still succeeds. It answers `notAuthenticated` once the sign-in has run out of time, and the visitor then signs in again from the start.
415
+
388
416
  ### Finishing a sign-in that needs an authenticator app
389
417
 
390
418
  On `step: 'verify'` the visitor already has an app, and owes a code from it:
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  # The current user
@@ -56,9 +56,9 @@ It returns `User | null` and never suspends or fetches. It selects off root rout
56
56
  ```tsx
57
57
  export const Route = createFileRoute('/_app/invoices')({
58
58
  beforeLoad: guardRoute(pageSettings),
59
- loader: (opts) => {
59
+ loader: async (opts) => {
60
60
  assertRouteAccess(pageSettings, opts)
61
- return fetchInvoicesFor({ data: { userId: opts.context.user.id } })
61
+ return { name: opts.context.user.fullName, invoices: await getMyInvoices() }
62
62
  },
63
63
  head: pageHead(pageSettings),
64
64
  })
@@ -66,6 +66,8 @@ export const Route = createFileRoute('/_app/invoices')({
66
66
 
67
67
  A `guardRoute` in `beforeLoad` guarantees a visitor at runtime, but it does not change what the compiler sees. `context.user` is still `User | null` in the loader. `assertRouteAccess(pageSettings, opts)` at the top of the loader narrows it there, so prefer that over `!`. Take `opts` whole: a destructured `context` does not narrow. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
68
68
 
69
+ Never pass the visitor's id from a loader into a server function. The loader also runs in the browser, so the id arrives as an argument anyone can set. `getMyInvoices` below reads the visitor from its own `context`.
70
+
69
71
  ### In a server function: `context`
70
72
 
71
73
  ```ts
@@ -7,7 +7,7 @@ description: >-
7
7
  metadata:
8
8
  type: sub-skill
9
9
  library: softr-sdk
10
- library_version: 0.0.91-alpha.1
10
+ library_version: 0.0.91
11
11
  ---
12
12
 
13
13
  **Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.91-alpha.1
11
+ library_version: 0.0.91
12
12
  ---
13
13
 
14
14
  # Dates, times and numbers
@@ -7,21 +7,21 @@ description: >-
7
7
  metadata:
8
8
  type: sub-skill
9
9
  library: softr-sdk
10
- library_version: 0.0.91-alpha.1
10
+ library_version: 0.0.91
11
11
  ---
12
12
 
13
13
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
14
14
 
15
15
  # Integrations
16
16
 
17
- `integration.createFetcher` calls a third-party API through Softr's authenticated proxy. It handles authorization automatically — you never set API keys or auth headers yourself.
17
+ `integration.createFetcher` calls a third-party API through Softr's authenticated proxy. It adds the connected account's credentials automatically — you never set API keys or auth headers yourself. **Every call acts as that one account, whoever the visitor is**, so take what a call reaches, such as a customer or a channel, from the session or a record the server read, never from the arguments the browser sends.
18
18
 
19
19
  ## Setup
20
20
 
21
21
  ```ts
22
22
  import { createServerFn } from '@tanstack/react-start'
23
23
  import { defineIntegration } from '@softr/sdk'
24
- import { integration } from '@softr/sdk/server'
24
+ import { auth, integration } from '@softr/sdk/server'
25
25
  ```
26
26
 
27
27
  ## Reading connected integrations from the prompt
@@ -77,7 +77,7 @@ const getCalendarEvents = createServerFn().handler(async () => {
77
77
  })
78
78
  ```
79
79
 
80
- For a dynamic path segment, pass `params` matching each `{placeholder}` token:
80
+ For a dynamic path segment, pass `params` matching each `{placeholder}` token. Here the customer comes from the visitor's own Users row, which `auth.currentUserRow` finds through `usersQuery`, the app's `defineQuery` on the Users table:
81
81
 
82
82
  ```ts
83
83
  const getStripeCustomer = defineIntegration({
@@ -89,10 +89,12 @@ const getStripeCustomer = defineIntegration({
89
89
  })
90
90
 
91
91
  const fetchCustomer = createServerFn({ method: 'GET' })
92
- .validator((data: { customerId: string }) => data)
93
- .handler(async ({ data }) => {
92
+ .handler(async ({ context }) => {
93
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
94
+ const customerId = me?.fields.stripeCustomerId
95
+ if (!customerId) throw new Error('No Stripe customer for this visitor')
94
96
  const stripe = integration.createFetcher(getStripeCustomer)
95
- const res = await stripe({ params: { id: data.customerId } })
97
+ const res = await stripe({ params: { id: customerId } })
96
98
  if (!res.ok) throw new Error(`Stripe error: ${res.status} ${await res.text()}`)
97
99
  return res.json()
98
100
  })
@@ -110,12 +112,12 @@ const postSlackMessage = defineIntegration({
110
112
  })
111
113
 
112
114
  const sendSlackMessage = createServerFn({ method: 'POST' })
113
- .validator((data: { channel: string; text: string }) => data)
115
+ .validator((data: { text: string }) => data)
114
116
  .handler(async ({ data }) => {
115
117
  const slack = integration.createFetcher(postSlackMessage)
116
118
  const res = await slack({
117
119
  headers: { 'Content-Type': 'application/json' },
118
- body: JSON.stringify({ channel: data.channel, text: data.text }),
120
+ body: JSON.stringify({ channel: '#sales', text: data.text }),
119
121
  })
120
122
  if (!res.ok) throw new Error(`Slack error: ${res.status} ${await res.text()}`)
121
123
  return res.json()
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  **Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
@@ -53,25 +53,10 @@ The list is the whole tool surface. Write it out:
53
53
 
54
54
  - An action you leave out is invisible to the assistant, even where the app's pages use it freely.
55
55
  - An action you list still obeys its own `permissions`. Listing it does not widen who may run it — the assistant runs as whoever signed in, with that user's groups.
56
- - The assistant calls the action directly and skips server functions, so a row filter written in one does not apply. A listed read returns every row the user's data restrictions allow, and a listed update or delete takes any id that passes them. On a table whose rows belong to people, list an action only once a data restriction scopes the table to the user's own rows.
57
- - A create on a table whose rows belong to a person gets its owner from `owner`, never from the assistant. See below.
58
56
  - Two actions with the same `id` throw at startup. So does anything that is not a `defineQuery` or `defineMutation` value.
59
57
 
60
58
  Reads first. Add a write only when the user asked for one, and name it back to them.
61
59
 
62
- ## The owner of a person's record
63
-
64
- The assistant knows the caller's login, not their row in the people table. Left to fill an owner link, it writes the login id, which shows as a stray name, or anyone's row it likes. `owner` takes the field out of the tool and fills it with the caller's own row, found the way `auth.currentUserRow` finds it:
65
-
66
- ```ts
67
- softrMcpMiddleware({
68
- actions: [listRequests, requestTimeOff],
69
- owner: { users: usersQuery, email: 'email', fields: { requestTimeOff: 'employee' } },
70
- })
71
- ```
72
-
73
- `fields` maps each create or update action id to its owner link. A caller with no row in the people table is refused, and nothing is created. On an update the field is only taken out, so nobody moves a record to someone else.
74
-
75
60
  ## The login round trip
76
61
 
77
62
  Connecting starts with an approval page the app serves. Most people hit it signed out, and that page cannot sign anyone in. It sends them to the login page named in `softr.json` (`auth.routes.login.path`), with the approval page in the search param named there (`auth.routes.login.redirectParam`).
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  # Installing the app
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.91-alpha.1
11
+ library_version: 0.0.91
12
12
  ---
13
13
 
14
14
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -127,6 +127,8 @@ Both take filters or nested groups:
127
127
 
128
128
  Nesting goes two levels, a group inside a group. A third level throws `Cannot nest filter groups more than three levels deep.` Flatten by distributing the outer condition rather than nesting further.
129
129
 
130
+ **An `$or` with no members matches every row.** When its members come from a list that can be empty, return no rows for an empty list instead of reading the table.
131
+
130
132
  ## Sorting
131
133
 
132
134
  ```ts
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
@@ -3,13 +3,13 @@ name: routes-and-visibility
3
3
  description: >-
4
4
  Add a route file and declare its settings: definePage with its visibility
5
5
  (LOGGED_IN_USERS, NON_LOGGED_IN_USERS and customGroups) and its title,
6
- guardRoute or assertRouteAccess in beforeLoad, pageHead in head, isAuthorized
7
- and checkAccess anywhere, rendering an access error, and the lint rules that
8
- require the declaration.
6
+ guardRoute or assertRouteAccess in beforeLoad, pageHead in head,
7
+ isAuthorized and checkAccess anywhere, rendering an access error, and the
8
+ lint rules that require the declaration.
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  **Boundary.** `definePage`, `pageHead`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
@@ -73,6 +73,8 @@ Everyone is both audiences, as in the first line above. A set names each audienc
73
73
 
74
74
  Take every id in `customGroups` from `docs/user-groups.md`, which lists each group the app has with its name and id. An id the app does not have is a type error.
75
75
 
76
+ **`visibility` guards the page, not the server functions it calls.** A call can reach them without the page and with any arguments, so the queries and mutations behind a page carry its groups in their own `permissions` and reach only the rows the visitor's role allows.
77
+
76
78
  ## Core Patterns
77
79
 
78
80
  ### Guard a whole section with one layout route
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.91-alpha.1
12
+ library_version: 0.0.91
13
13
  ---
14
14
 
15
15
  **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -1,30 +0,0 @@
1
- import { z } from "zod";
2
- import * as _tanstack_react_start8 from "@tanstack/react-start";
3
-
4
- //#region src/agent/agent-fns.d.ts
5
-
6
- declare const agentSchema: z.ZodObject<{
7
- id: z.ZodString;
8
- title: z.ZodString;
9
- avatar: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
- thumbnail: z.ZodOptional<z.ZodNullable<z.ZodString>>;
11
- }, z.core.$strip>;
12
- /** An agent the visitor may chat with. */
13
- type Agent = z.infer<typeof agentSchema>;
14
- /**
15
- * The agents the visitor may chat with in this app. The builder attaches them
16
- * in Studio, and each one's visibility settings decide who sees it.
17
- */
18
- declare const listAgents: _tanstack_react_start8.OptionalFetcher<undefined, undefined, Promise<{
19
- id: string;
20
- title: string;
21
- avatar?: string | null | undefined;
22
- thumbnail?: string | null | undefined;
23
- }[]>>;
24
- /** One past chat with an agent. `title` is null until the agent names the chat. */
25
- type AgentChatSummary = {
26
- id: string;
27
- title: string | null;
28
- };
29
- //#endregion
30
- export { Agent, AgentChatSummary, listAgents };
@@ -1,215 +0,0 @@
1
- import { assert } from "../assertions.js";
2
- import { softrApiUrl } from "../softr-api.js";
3
- import { fetch } from "../fetch.js";
4
- import { sessionConfig } from "../auth/config.js";
5
- import { readSessionToken } from "../auth/session.js";
6
- import { describeFailure, refererHeader } from "../datasource/record.js";
7
- import { z } from "zod";
8
- import { createServerFn } from "@tanstack/react-start";
9
- import { getRequest } from "@tanstack/react-start/server";
10
-
11
- //#region src/agent/agent-fns.ts
12
- const AGENTS_PRODUCTION = "https://agents-api.softr.io";
13
- const PLATFORM_HOST = /^(platform-api|platform-preview-api|preview-api)(?=[.-])/;
14
- /**
15
- * Where the agents service is. It sits next to the platform API, so unless
16
- * `SOFTR_AGENTS_API_URL` says otherwise, it's the platform's host with
17
- * `agents-api` in place of the platform's name. Live and preview share one
18
- * agents service.
19
- */
20
- function agentsApiUrl() {
21
- const explicit = process.env.SOFTR_AGENTS_API_URL;
22
- if (explicit) return explicit.replace(/\/+$/, "");
23
- if (!process.env.SOFTR_PLATFORM_API_URL) return AGENTS_PRODUCTION;
24
- const platform = new URL(softrApiUrl("SOFTR_PLATFORM_API_URL"));
25
- assert(PLATFORM_HOST.test(platform.hostname), () => `can't tell the agents API from ${platform.origin}, set SOFTR_AGENTS_API_URL`);
26
- platform.hostname = platform.hostname.replace(PLATFORM_HOST, "agents-api");
27
- return platform.origin;
28
- }
29
- function agentsFetch(path, init = {}) {
30
- const token = readSessionToken();
31
- return fetch(`${agentsApiUrl()}/v1/agents/${path}`, {
32
- method: init.method ?? "GET",
33
- signal: init.signal,
34
- headers: {
35
- "Content-Type": "application/json",
36
- ...token ? { Authorization: `Bearer ${token}` } : {},
37
- "Softr-Application-ID": sessionConfig().applicationId,
38
- ...refererHeader(),
39
- ...init.headers
40
- },
41
- body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
42
- });
43
- }
44
- const chatSchema = z.object({
45
- agentId: z.string().min(1),
46
- message: z.string(),
47
- sessionId: z.string().nullable(),
48
- timezone: z.string(),
49
- pageName: z.string(),
50
- pageUrl: z.string(),
51
- record: z.object({
52
- id: z.string(),
53
- fields: z.record(z.string(), z.unknown())
54
- }).nullable()
55
- });
56
- /**
57
- * Sends one message and streams the reply back as it comes, one JSON event per line.
58
- *
59
- * Takes FormData so the browser can call it with plain `fetch` and read the
60
- * stream itself. A failed call passes the agents service's status and message on.
61
- */
62
- const agentChatFn = createServerFn({ method: "POST" }).validator((data) => {
63
- const payload = data.get("payload");
64
- assert(typeof payload === "string", "agent chat: payload is required");
65
- return chatSchema.parse(JSON.parse(payload));
66
- }).handler(async ({ data }) => {
67
- const upstream = await agentsFetch(`${encodeURIComponent(data.agentId)}/chat`, {
68
- method: "POST",
69
- signal: getRequest().signal,
70
- headers: {
71
- Accept: "application/x-ndjson",
72
- "X-User-Timezone": data.timezone
73
- },
74
- body: {
75
- message: data.message,
76
- ...data.sessionId ? { sessionId: data.sessionId } : {},
77
- context: {
78
- pageName: data.pageName,
79
- pageUrl: data.pageUrl,
80
- ...data.record ? { currentRecord: { data: data.record } } : {}
81
- }
82
- }
83
- });
84
- return new Response(upstream.body, {
85
- status: upstream.status,
86
- headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/x-ndjson" }
87
- });
88
- });
89
- const agentSchema = z.object({
90
- id: z.string(),
91
- title: z.string(),
92
- avatar: z.string().nullish(),
93
- thumbnail: z.string().nullish()
94
- });
95
- /**
96
- * The agents the visitor may chat with in this app. The builder attaches them
97
- * in Studio, and each one's visibility settings decide who sees it.
98
- */
99
- const listAgents = createServerFn({ method: "GET" }).handler(async () => {
100
- const token = readSessionToken();
101
- const res = await fetch(`${softrApiUrl("SOFTR_PLATFORM_API_URL")}/v1/vibe/applications/${sessionConfig().applicationId}/agents`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
102
- if (!res.ok) throw new Error(`Loading agents failed: ${await describeFailure(res)}`);
103
- return z.array(agentSchema).parse(await res.json());
104
- });
105
- const listAgentChatsFn = createServerFn({ method: "POST" }).validator(z.object({ agentId: z.string().min(1) })).handler(async ({ data }) => {
106
- const res = await agentsFetch(`${encodeURIComponent(data.agentId)}/sessions`);
107
- if (!res.ok) throw new Error(`Loading chats failed: ${await describeFailure(res)}`);
108
- return z.array(z.object({
109
- id: z.string(),
110
- title: z.string().nullable()
111
- })).parse(await res.json());
112
- });
113
- const historySchema = z.object({
114
- messages: z.array(z.object({
115
- id: z.string(),
116
- role: z.enum(["USER", "ASSISTANT"]),
117
- content: z.string(),
118
- createdAt: z.number()
119
- })),
120
- toolCalls: z.array(z.object({
121
- id: z.string(),
122
- messageId: z.string(),
123
- toolName: z.string(),
124
- input: z.string(),
125
- createdAt: z.number(),
126
- metadata: z.object({
127
- innerThought: z.string().nullish(),
128
- durationMs: z.number().nullish(),
129
- failed: z.boolean().nullish()
130
- })
131
- }))
132
- });
133
- const CHAT_GONE = [
134
- 401,
135
- 403,
136
- 404,
137
- 410
138
- ];
139
- /** What a past chat holds, or `null` when it's gone or belongs to someone else. */
140
- const loadAgentChatFn = createServerFn({ method: "POST" }).validator(z.object({
141
- agentId: z.string().min(1),
142
- sessionId: z.string().min(1)
143
- })).handler(async ({ data }) => {
144
- const res = await agentsFetch(`${encodeURIComponent(data.agentId)}/sessions/${encodeURIComponent(data.sessionId)}`);
145
- if (CHAT_GONE.includes(res.status)) return null;
146
- if (!res.ok) throw new Error(`Loading the chat failed: ${await describeFailure(res)}`);
147
- return historySchema.parse(await res.json());
148
- });
149
- /** Saves the visitor's answer. `false` when the chat is gone, see `loadAgentChatFn`. */
150
- const decideAgentToolFn = createServerFn({ method: "POST" }).validator(z.object({
151
- agentId: z.string().min(1),
152
- sessionId: z.string().min(1),
153
- toolCallId: z.string().min(1),
154
- toolName: z.string().min(1),
155
- decision: z.enum(["APPROVE", "REJECT"]),
156
- scope: z.enum([
157
- "ONCE",
158
- "SESSION",
159
- "USER"
160
- ])
161
- })).handler(async ({ data: { agentId,...decision } }) => {
162
- const res = await agentsFetch(`${encodeURIComponent(agentId)}/tool-permission-decisions`, {
163
- method: "POST",
164
- body: decision
165
- });
166
- if (CHAT_GONE.includes(res.status)) return false;
167
- if (!res.ok) throw new Error(`Saving the answer failed: ${await describeFailure(res)}`);
168
- return true;
169
- });
170
- const connectionSchema = z.object({
171
- integration: z.string().min(1),
172
- requestId: z.string().min(1)
173
- });
174
- function connectionUrl({ integration, requestId }, step) {
175
- const { applicationId } = sessionConfig();
176
- const provider = encodeURIComponent(integration.toLowerCase());
177
- return `${`${softrApiUrl("SOFTR_DATASOURCE_API_URL")}/v1/datasource/applications/${applicationId}`}/integrations/${provider}/oauth/${step}?${new URLSearchParams({ requestId })}`;
178
- }
179
- /** The sign-in page to open in the popup. */
180
- const startAgentConnectionFn = createServerFn({ method: "POST" }).validator(connectionSchema).handler(async ({ data }) => {
181
- const token = readSessionToken();
182
- const res = await fetch(connectionUrl(data, "register"), {
183
- method: "POST",
184
- headers: {
185
- ...token ? { Authorization: `Bearer ${token}` } : {},
186
- ...refererHeader()
187
- }
188
- });
189
- if (!res.ok) throw new Error(`Starting the connection failed: ${await describeFailure(res)}`);
190
- return z.object({ url: z.string() }).parse(await res.json()).url;
191
- });
192
- /** Waits a while for the visitor to finish signing in. `false` when they haven't yet. */
193
- const awaitAgentConnectionFn = createServerFn({ method: "POST" }).validator(connectionSchema).handler(async ({ data }) => {
194
- const res = await fetch(connectionUrl(data, "register-result"), { signal: getRequest().signal });
195
- if (res.status === 503) return false;
196
- if (!res.ok) throw new Error(`Connecting failed: ${await describeFailure(res)}`);
197
- return true;
198
- });
199
- /** Tells the agent the account is connected. `false` when the chat is gone, see `loadAgentChatFn`. */
200
- const finishAgentConnectionFn = createServerFn({ method: "POST" }).validator(z.object({
201
- agentId: z.string().min(1),
202
- sessionId: z.string().min(1),
203
- toolCallId: z.string().min(1)
204
- })).handler(async ({ data: { agentId,...completion } }) => {
205
- const res = await agentsFetch(`${encodeURIComponent(agentId)}/integration-auth-completions`, {
206
- method: "POST",
207
- body: completion
208
- });
209
- if (CHAT_GONE.includes(res.status)) return false;
210
- if (!res.ok) throw new Error(`Finishing the connection failed: ${await describeFailure(res)}`);
211
- return true;
212
- });
213
-
214
- //#endregion
215
- export { agentChatFn, awaitAgentConnectionFn, decideAgentToolFn, finishAgentConnectionFn, listAgentChatsFn, listAgents, loadAgentChatFn, startAgentConnectionFn };