@tapi-dev/sdk 0.1.17 → 0.1.20

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
@@ -10,167 +10,266 @@ npm install @tapi-dev/sdk
10
10
 
11
11
  This package is ESM-first and works in runtimes with `fetch`, including modern Node.js and browser-like server runtimes.
12
12
 
13
- ## Developer Flow
14
-
15
- Tapi Studio is launched from the developer app repo. The repo's `.tapi/project.json`
16
- is the project binding, so one developer can work on multiple Tapi projects from
17
- different directories without a global Studio project picker.
18
-
19
- ```bash
20
- npm install @tapi-dev/sdk
21
- npx tapi init --project brokerage
22
- npx tapi studio
23
- ```
24
-
25
- `tapi studio` checks the required local `tapi-service`, installs it when needed,
26
- downloads the portable Studio server release when the channel manifest uses
27
- `installerKind: portable-server`, starts Studio locally, and opens the browser.
28
- If the channel still publishes a legacy NSIS desktop Studio manifest and no
29
- Studio executable is installed yet, `tapi studio` runs the installer first, then
30
- opens Studio. The normal developer path is the portable server launched by the
31
- CLI.
32
-
33
- By default, Studio downloads and extracted portable server releases live under
34
- the user's local app data directory. On Windows that is usually
35
- `%LOCALAPPDATA%\Tapi\Studio`. To keep the whole Studio install on another drive,
36
- set `TAPI_STUDIO_HOME` before launching:
37
-
38
- ```powershell
39
- $env:TAPI_STUDIO_HOME="F:\Tapi\Studio"
40
- npx tapi studio
41
- ```
42
-
43
- For separate paths, use `--cache-dir` for downloaded zip artifacts and
44
- `--install-dir` for extracted portable Studio server releases.
45
-
46
- After a successful install, the CLI prunes older Tapi-managed download artifacts
47
- from the cache and older SDK-marked release folders from the install directory.
48
- The current artifact and current release are kept.
49
-
50
- Useful commands:
51
-
52
- ```bash
53
- npx tapi init --project brokerage
54
- npx tapi link --project brokerage
55
- npx tapi studio
56
- npx tapi service install --channel pilot
57
- npx tapi service status
58
- npx tapi apis generate
59
- npx tapi publish
60
- ```
61
-
62
- Local authoring files live in the app repo:
63
-
64
- ```text
65
- .tapi/project.json
66
- .tapi/sitemaps/<site>.json
67
- .tapi/apis/<site>/<api>.json
68
- .tapi/generated/catalog.json
69
- src/tapi.generated.ts
70
- ```
71
-
72
- Drafts are local. `tapi publish` uploads local sitemaps and API contracts, marks
73
- ready requests as published, and activates one immutable server release. Runtime
74
- SDK calls read the active release catalog, not mutable Studio drafts.
75
-
76
- ```bash
77
- TAPI_API_KEY=tapi_project_key npx tapi publish
78
- TAPI_API_KEY=tapi_project_key npx tapi apis generate
13
+ The CLI command is `tapi`. If your package manager only installed the SDK
14
+ locally and `tapi` is not on `PATH`, use `npx tapi` as a fallback.
15
+
16
+ ## Developer Flow
17
+
18
+ Tapi Studio is launched from the developer app repo. The repo's `.tapi/project.json`
19
+ is the project binding, so one developer can work on multiple Tapi projects from
20
+ different directories without a global Studio project picker.
21
+
22
+ ```bash
23
+ npm install @tapi-dev/sdk
24
+ tapi init --project brokerage
25
+ tapi studio
26
+ ```
27
+
28
+ `tapi studio` checks the required local `tapi-service`, installs it when needed,
29
+ downloads the portable Studio server release when the channel manifest uses
30
+ `installerKind: portable-server`, starts Studio locally, and opens the browser.
31
+ If the channel still publishes a legacy NSIS desktop Studio manifest and no
32
+ Studio executable is installed yet, `tapi studio` runs the installer first, then
33
+ opens Studio. The normal developer path is the portable server launched by the
34
+ CLI.
35
+
36
+ By default, Studio downloads and extracted portable server releases live under
37
+ the user's local app data directory. On Windows that is usually
38
+ `%LOCALAPPDATA%\Tapi\Studio`. To keep the whole Studio install on another drive,
39
+ set `TAPI_STUDIO_HOME` before launching:
40
+
41
+ ```powershell
42
+ $env:TAPI_STUDIO_HOME="F:\Tapi\Studio"
43
+ tapi studio
44
+ ```
45
+
46
+ For separate paths, use `--cache-dir` for downloaded zip artifacts and
47
+ `--install-dir` for extracted portable Studio server releases.
48
+
49
+ After a successful install, the CLI prunes older Tapi-managed download artifacts
50
+ from the cache and older SDK-marked release folders from the install directory.
51
+ The current artifact and current release are kept.
52
+
53
+ Useful commands:
54
+
55
+ ```bash
56
+ tapi init --project brokerage
57
+ tapi link --project brokerage
58
+ tapi studio
59
+ tapi service install --channel pilot
60
+ tapi service status
61
+ tapi apis generate
62
+ tapi sessions
63
+ tapi publish
64
+ ```
65
+
66
+ Local authoring files live in the app repo:
67
+
68
+ ```text
69
+ .tapi/project.json
70
+ .tapi/sitemaps/<site>.json
71
+ .tapi/apis/<site>/<api>.json
72
+ .tapi/generated/catalog.json
73
+ src/tapi.generated.ts
74
+ ```
75
+
76
+ Drafts are local. `tapi publish` uploads local sitemaps and API contracts, marks
77
+ ready requests as published, and activates one immutable server release. Runtime
78
+ SDK calls read the active release catalog, not mutable Studio drafts.
79
+
80
+ ```bash
81
+ TAPI_API_KEY=tapi_project_key tapi publish
82
+ TAPI_API_KEY=tapi_project_key tapi apis generate
83
+ ```
84
+
85
+ ## Quick Start
86
+
87
+ Create one TAPI client in server-side app code. Use a project-scoped API key and
88
+ the same project id from `.tapi/project.json`.
89
+
90
+ ```ts
91
+ import { TapiClient } from "@tapi-dev/sdk";
92
+
93
+ export const tapi = new TapiClient({
94
+ baseUrl: process.env.TAPI_BASE_URL!,
95
+ apiKey: process.env.TAPI_API_KEY!,
96
+ projectId: process.env.TAPI_PROJECT_ID!,
97
+ });
98
+ ```
99
+
100
+ Production is the default mode. Unknown website states fail the run instead of
101
+ holding a browser open:
102
+
103
+ ```ts
104
+ const tapi = new TapiClient({
105
+ baseUrl: process.env.TAPI_BASE_URL!,
106
+ apiKey: process.env.TAPI_API_KEY!,
107
+ projectId: process.env.TAPI_PROJECT_ID!,
108
+ dev: false,
109
+ });
110
+ ```
111
+
112
+ During development, set `dev: true`. If a website reaches an unknown state,
113
+ Tapi preserves the browser session for takeover instead of turning it into a
114
+ production failure:
115
+
116
+ ```ts
117
+ const tapi = new TapiClient({
118
+ baseUrl: process.env.TAPI_BASE_URL!,
119
+ apiKey: process.env.TAPI_API_KEY!,
120
+ projectId: process.env.TAPI_PROJECT_ID!,
121
+ dev: true,
122
+ });
123
+ ```
124
+
125
+ Inspect preserved sessions from the repo:
126
+
127
+ ```bash
128
+ tapi sessions
129
+ tapi sessions --json
130
+ tapi sessions open <session-id>
79
131
  ```
80
132
 
81
- ## Quick Start
82
-
83
- Create one TAPI client in server-side app code. Use a project-scoped API key and
84
- the same project id from `.tapi/project.json`.
85
-
86
- ```ts
87
- import { TapiClient } from "@tapi-dev/sdk";
88
-
89
- export const tapi = new TapiClient({
90
- baseUrl: process.env.TAPI_BASE_URL!,
91
- apiKey: process.env.TAPI_API_KEY!,
92
- projectId: process.env.TAPI_PROJECT_ID!,
93
- });
94
- ```
133
+ `tapi sessions open` reuses the matching Studio window for the current repo when
134
+ one is already running; otherwise it installs/starts Studio and opens directly
135
+ to that session.
95
136
 
96
137
  Generated website APIs are authored visually in Tapi Studio by setting workflow
97
138
  bounds, selecting inputs/outputs, and publishing an API. The SDK sees the public
98
139
  operation name:
99
-
100
- ```text
101
- <namespace>.<operation>
102
- ```
103
-
104
- ```ts
105
- const operation = await tapi.websiteApis.describe("schwab.place_order");
106
-
107
- const run = await tapi.websiteApis.run("schwab.place_order", {
108
- inputs: {
109
- symbol: "AAPL",
110
- side: "buy",
111
- quantity: 10,
112
- orderType: "limit",
113
- limitPrice: 190,
114
- },
115
- });
116
-
117
- const completedRun = await tapi.runs.wait(run.id);
118
- console.log(completedRun.status, completedRun.result);
119
- ```
120
-
121
- ## API-Call Triggers
122
-
123
- Triggers are scheduled calls to published website APIs. Keep trigger definitions
124
- in the app repo and sync them after publishing the API catalog:
125
-
126
- ```ts
127
- // tapi.config.ts
128
- export default {
129
- triggers: {
130
- nightlyBalance: {
131
- apiRequest: "schwab.get_balance",
132
- schedule: { cron: "0 9 * * MON-FRI" },
133
- inputs: { accountId: "main" },
134
- runtime: { profileRef: "perm_default" },
135
- },
136
- },
137
- };
138
- ```
139
-
140
- ```bash
141
- TAPI_API_KEY=tapi_project_key npx tapi triggers sync
142
- ```
143
-
144
- `triggers sync` upserts by trigger name for the current `.tapi/project.json`
145
- project. Schedules support standard five-field cron strings or intervals as
146
- numbers of seconds / strings like `30s`, `15m`, `2h`, and `1d`.
147
-
148
- You can also manage triggers directly:
149
-
150
- ```ts
151
- await tapi.triggers.create({
152
- name: "nightlyBalance",
153
- apiRequest: "schwab.get_balance",
154
- schedule: { cron: "0 9 * * MON-FRI" },
155
- inputs: { accountId: "main" },
156
- runtime: { profileRef: "perm_default" },
157
- });
158
-
159
- await tapi.triggers.fire("act_123");
160
- await tapi.triggers.disable("act_123");
161
- ```
162
-
163
- You can inspect the same input/output contract from the CLI:
164
-
165
- ```bash
166
- npx tapi apis describe schwab.place_order \
167
- --api-base-url "$TAPI_BASE_URL" \
168
- --api-key "$TAPI_API_KEY" \
169
- --project "$TAPI_PROJECT_ID"
170
- ```
171
-
172
- Do not expose `TAPI_API_KEY` in public browser bundles. Put the SDK behind your
173
- own backend route, server action, or job worker when using secret API keys.
140
+
141
+ ```text
142
+ <namespace>.<operation>
143
+ ```
144
+
145
+ ```ts
146
+ const operation = await tapi.websiteApis.describe("schwab.place_order");
147
+
148
+ const run = await tapi.websiteApis.run("schwab.place_order", {
149
+ inputs: {
150
+ symbol: "AAPL",
151
+ side: "buy",
152
+ quantity: 10,
153
+ orderType: "limit",
154
+ limitPrice: 190,
155
+ },
156
+ });
157
+
158
+ const completedRun = await tapi.runs.wait(run.id);
159
+ console.log(completedRun.status, completedRun.result);
160
+ ```
161
+
162
+ ## Late Inputs
163
+
164
+ Most API inputs are known before the run starts. Use `lateInput()` when the
165
+ workflow needs a value later, after earlier browser steps have triggered work
166
+ somewhere else.
167
+
168
+ For example, state 4 might submit a login form and trigger a one-time code by
169
+ email or SMS. State 5 needs that code, but your app can only retrieve it after
170
+ state 4 runs. Declare the input up front with the API call, then resolve it
171
+ from your own code:
172
+
173
+ ```ts
174
+ import { lateInput } from "@tapi-dev/sdk";
175
+
176
+ const verificationCode = lateInput<string>({
177
+ resolve: async () => {
178
+ return await waitForVerificationCodeFromEmail();
179
+ },
180
+ timeoutMs: 180_000,
181
+ });
182
+
183
+ const run = await tapi.websiteApis.run("walmart.login", {
184
+ inputs: {
185
+ email: "buyer@example.com",
186
+ password: process.env.WALMART_PASSWORD!,
187
+ verificationCode,
188
+ },
189
+ });
190
+
191
+ await verificationCode.waitForProvides();
192
+
193
+ const completedRun = await tapi.runs.wait(run.id);
194
+ ```
195
+
196
+ You can also provide the value manually. This is useful when another callback,
197
+ worker, webhook, or polling loop finds the value:
198
+
199
+ ```ts
200
+ const verificationCode = lateInput<string>({ timeoutMs: 180_000 });
201
+
202
+ const run = await tapi.websiteApis.run("walmart.login", {
203
+ inputs: { email: "buyer@example.com", verificationCode },
204
+ });
205
+
206
+ const code = await waitForVerificationCodeFromWebhook();
207
+ await verificationCode.set(code);
208
+
209
+ const completedRun = await tapi.runs.wait(run.id);
210
+ ```
211
+
212
+ Tapi only waits when the runner reaches the action bound to that input. If your
213
+ app resolves the value early, Tapi stores it and uses it later. If the value is
214
+ not provided before `timeoutMs`, the run fails instead of guessing or using the
215
+ recorded default.
216
+
217
+ Late inputs are for values produced outside the website flow. Values read from
218
+ the website itself should stay as normal API outputs.
219
+
220
+ ## API-Call Triggers
221
+
222
+ Triggers are scheduled calls to published website APIs. Keep trigger definitions
223
+ in the app repo and sync them after publishing the API catalog:
224
+
225
+ ```ts
226
+ // tapi.config.ts
227
+ export default {
228
+ triggers: {
229
+ nightlyBalance: {
230
+ apiRequest: "schwab.get_balance",
231
+ schedule: { cron: "0 9 * * MON-FRI" },
232
+ inputs: { accountId: "main" },
233
+ runtime: { profileRef: "perm_default" },
234
+ },
235
+ },
236
+ };
237
+ ```
238
+
239
+ ```bash
240
+ TAPI_API_KEY=tapi_project_key tapi triggers sync
241
+ ```
242
+
243
+ `triggers sync` upserts by trigger name for the current `.tapi/project.json`
244
+ project. Schedules support standard five-field cron strings or intervals as
245
+ numbers of seconds / strings like `30s`, `15m`, `2h`, and `1d`.
246
+
247
+ You can also manage triggers directly:
248
+
249
+ ```ts
250
+ await tapi.triggers.create({
251
+ name: "nightlyBalance",
252
+ apiRequest: "schwab.get_balance",
253
+ schedule: { cron: "0 9 * * MON-FRI" },
254
+ inputs: { accountId: "main" },
255
+ runtime: { profileRef: "perm_default" },
256
+ });
257
+
258
+ await tapi.triggers.fire("act_123");
259
+ await tapi.triggers.disable("act_123");
260
+ ```
261
+
262
+ You can inspect the same input/output contract from the CLI:
263
+
264
+ ```bash
265
+ tapi apis describe schwab.place_order \
266
+ --api-base-url "$TAPI_BASE_URL" \
267
+ --api-key "$TAPI_API_KEY" \
268
+ --project "$TAPI_PROJECT_ID"
269
+ ```
270
+
271
+ Do not expose `TAPI_API_KEY` in public browser bundles. Put the SDK behind your
272
+ own backend route, server action, or job worker when using secret API keys.
174
273
 
175
274
  ## Runtime Profiles and Proxies
176
275
 
@@ -241,17 +340,17 @@ const tapi = new TapiClient({
241
340
  });
242
341
  ```
243
342
 
244
- ## Configuration
245
-
246
- ```env
247
- TAPI_BASE_URL=https://your-tapi-api-host
248
- TAPI_API_KEY=tapi_project_key
249
- TAPI_PROJECT_ID=brokerage
250
- ```
251
-
252
- `projectId` is optional only when the API key itself is project-scoped. If
253
- provided, the SDK sends it as the `X-Tapi-Project` header and the server rejects
254
- mismatches.
343
+ ## Configuration
344
+
345
+ ```env
346
+ TAPI_BASE_URL=https://your-tapi-api-host
347
+ TAPI_API_KEY=tapi_project_key
348
+ TAPI_PROJECT_ID=brokerage
349
+ ```
350
+
351
+ `projectId` is optional only when the API key itself is project-scoped. If
352
+ provided, the SDK sends it as the `X-Tapi-Project` header and the server rejects
353
+ mismatches.
255
354
 
256
355
  ## Common Project Setup
257
356
 
@@ -267,12 +366,12 @@ src/
267
366
  // src/lib/tapi.ts
268
367
  import { TapiClient } from "@tapi-dev/sdk";
269
368
 
270
- export const tapi = new TapiClient({
271
- baseUrl: process.env.TAPI_BASE_URL!,
272
- apiKey: process.env.TAPI_API_KEY!,
273
- projectId: process.env.TAPI_PROJECT_ID!,
274
- });
275
- ```
369
+ export const tapi = new TapiClient({
370
+ baseUrl: process.env.TAPI_BASE_URL!,
371
+ apiKey: process.env.TAPI_API_KEY!,
372
+ projectId: process.env.TAPI_PROJECT_ID!,
373
+ });
374
+ ```
276
375
 
277
376
  Application code should import this shared client instead of constructing a new client in every file.
278
377
 
@@ -297,126 +396,127 @@ const run = await tapi.websiteApis.run("apiName.requestKey", {
297
396
  });
298
397
 
299
398
  await tapi.runs.get(run.id);
300
- await tapi.runs.wait(run.id, { intervalMs: 1000, timeoutMs: 300000 });
301
- await tapi.runs.cancel(run.id);
302
- ```
303
-
304
- ## Cloud Batch Runs
305
-
306
- Cloud runs are requested from the SDK, but VM provisioning, service
307
- installation, worker leases, browser internals, and AWS cleanup stay on the
308
- Tapi server. The SDK is only the front door.
309
-
310
- Keep this code server-side. Do not put `TAPI_API_KEY` in a browser bundle and
311
- do not wire AWS or Stripe from the developer's app. The app sends its own user
312
- identity to Tapi, and Tapi handles prepaid Stripe checkout, credit accounting,
313
- AWS worker provisioning, and cleanup.
314
-
315
- The minimum developer flow is:
316
-
317
- 1. App user clicks a button.
318
- 2. The developer's backend calls `runCloudBatch` with `user.externalUserId`.
319
- 3. If the returned run has `status: "payment_required"`, redirect the app user
320
- to `run.checkoutUrl`.
321
- 4. Stripe calls the Tapi webhook, Tapi credits that same `externalUserId`, and
322
- the app retries the cloud batch.
323
- 5. If credit is available, Tapi reserves the balance and starts cloud workers.
324
-
325
- ```ts
326
- // backend route or server action
327
- const quote = await tapi.websiteApis.quoteCloud("schwab.place_order", {
328
- inputs: [
329
- { symbol: "AAPL", quantity: 1 },
330
- { symbol: "MSFT", quantity: 2 },
331
- ],
332
- user: {
333
- externalUserId: appUser.id,
334
- email: appUser.email,
335
- },
336
- cloud: {
337
- windows: true,
338
- maxVms: 1,
339
- chromePerVm: 10,
340
- maxCostUsd: 20,
341
- },
342
- });
343
-
344
- if (!quote.withinMaxCost) {
345
- throw new Error("Cloud batch exceeds the configured cost cap");
346
- }
347
-
348
- const run = await tapi.websiteApis.runCloudBatch("schwab.place_order", {
349
- inputs: [
350
- { symbol: "AAPL", quantity: 1 },
351
- { symbol: "MSFT", quantity: 2 },
352
- ],
353
- user: {
354
- externalUserId: appUser.id,
355
- email: appUser.email,
356
- },
357
- payment: {
358
- successUrl: `https://your-app.example/cloud/success`,
359
- cancelUrl: `https://your-app.example/cloud/cancel`,
360
- },
361
- cloud: {
362
- windows: true,
363
- maxVms: 1,
364
- chromePerVm: 10,
365
- maxCostUsd: 20,
366
- },
367
- });
368
-
369
- if (run.status === "payment_required") {
370
- return { redirectTo: run.checkoutUrl };
371
- }
372
-
373
- for await (const event of tapi.cloudRuns.stream(run.id)) {
374
- console.log(event.status, event.traceId);
375
- }
376
-
377
- const results = await tapi.cloudRuns.results(run.id);
378
- ```
379
-
380
- When `CLOUD_BILLING_ENABLED=true`, the server refuses to launch AWS workers
381
- unless prepaid cloud credit is available and reserved first. If balance is too
382
- low, `runCloudBatch` converts the server's HTTP `402` into a normal
383
- `CloudBatchRun` object with `status: "payment_required"`, `checkoutUrl`,
384
- `availableBalanceCents`, and `requiredBalanceCents`. That keeps the button
385
- handler simple.
386
-
387
- Credits are scoped under the Tapi API key owner, app id, and
388
- `user.externalUserId`. Use the same `externalUserId` for quote, balance,
389
- checkout, and run calls. If you omit `externalUserId` but provide `email`, Tapi
390
- uses the normalized email as the external user id.
391
-
392
- Applications can also create a top-up checkout explicitly:
393
-
394
- ```ts
395
- const user = {
396
- externalUserId: appUser.id,
397
- email: appUser.email,
398
- };
399
-
400
- const balance = await tapi.cloudRuns.balance(user);
401
-
402
- if (balance.balanceCents < balance.minimumBalanceCents) {
403
- const checkout = await tapi.cloudRuns.checkout({
404
- amountCents: balance.minimumBalanceCents,
405
- user,
406
- successUrl: "https://your-app.example/cloud/success",
407
- cancelUrl: "https://your-app.example/cloud/cancel",
408
- });
409
- console.log(checkout.checkoutUrl);
410
- }
411
- ```
412
-
413
- The developer application does not need VM provisioning logic, installer
414
- deployment logic, worker command interpretation, browser runner internals, or
415
- builder logic. Those stay behind the Tapi server API.
416
-
417
- Website API requests are addressed as `<namespace>.<operation>`. The namespace
418
- comes from the generated website name in Studio. The operation is the name the
419
- developer types into the SDK name textbox at the workflow boundary.
399
+ await tapi.runs.wait(run.id, { intervalMs: 1000, timeoutMs: 300000 });
400
+ await tapi.runs.provideInput(run.id, "verificationCode", "123456");
401
+ await tapi.runs.cancel(run.id);
402
+ ```
403
+
404
+ ## Cloud Batch Runs
405
+
406
+ Cloud runs are requested from the SDK, but VM provisioning, service
407
+ installation, worker leases, browser internals, and AWS cleanup stay on the
408
+ Tapi server. The SDK is only the front door.
409
+
410
+ Keep this code server-side. Do not put `TAPI_API_KEY` in a browser bundle and
411
+ do not wire AWS or Stripe from the developer's app. The app sends its own user
412
+ identity to Tapi, and Tapi handles prepaid Stripe checkout, credit accounting,
413
+ AWS worker provisioning, and cleanup.
414
+
415
+ The minimum developer flow is:
416
+
417
+ 1. App user clicks a button.
418
+ 2. The developer's backend calls `runCloudBatch` with `user.externalUserId`.
419
+ 3. If the returned run has `status: "payment_required"`, redirect the app user
420
+ to `run.checkoutUrl`.
421
+ 4. Stripe calls the Tapi webhook, Tapi credits that same `externalUserId`, and
422
+ the app retries the cloud batch.
423
+ 5. If credit is available, Tapi reserves the balance and starts cloud workers.
424
+
425
+ ```ts
426
+ // backend route or server action
427
+ const quote = await tapi.websiteApis.quoteCloud("schwab.place_order", {
428
+ inputs: [
429
+ { symbol: "AAPL", quantity: 1 },
430
+ { symbol: "MSFT", quantity: 2 },
431
+ ],
432
+ user: {
433
+ externalUserId: appUser.id,
434
+ email: appUser.email,
435
+ },
436
+ cloud: {
437
+ windows: true,
438
+ maxVms: 1,
439
+ chromePerVm: 10,
440
+ maxCostUsd: 20,
441
+ },
442
+ });
443
+
444
+ if (!quote.withinMaxCost) {
445
+ throw new Error("Cloud batch exceeds the configured cost cap");
446
+ }
447
+
448
+ const run = await tapi.websiteApis.runCloudBatch("schwab.place_order", {
449
+ inputs: [
450
+ { symbol: "AAPL", quantity: 1 },
451
+ { symbol: "MSFT", quantity: 2 },
452
+ ],
453
+ user: {
454
+ externalUserId: appUser.id,
455
+ email: appUser.email,
456
+ },
457
+ payment: {
458
+ successUrl: `https://your-app.example/cloud/success`,
459
+ cancelUrl: `https://your-app.example/cloud/cancel`,
460
+ },
461
+ cloud: {
462
+ windows: true,
463
+ maxVms: 1,
464
+ chromePerVm: 10,
465
+ maxCostUsd: 20,
466
+ },
467
+ });
468
+
469
+ if (run.status === "payment_required") {
470
+ return { redirectTo: run.checkoutUrl };
471
+ }
472
+
473
+ for await (const event of tapi.cloudRuns.stream(run.id)) {
474
+ console.log(event.status, event.traceId);
475
+ }
476
+
477
+ const results = await tapi.cloudRuns.results(run.id);
478
+ ```
479
+
480
+ When `CLOUD_BILLING_ENABLED=true`, the server refuses to launch AWS workers
481
+ unless prepaid cloud credit is available and reserved first. If balance is too
482
+ low, `runCloudBatch` converts the server's HTTP `402` into a normal
483
+ `CloudBatchRun` object with `status: "payment_required"`, `checkoutUrl`,
484
+ `availableBalanceCents`, and `requiredBalanceCents`. That keeps the button
485
+ handler simple.
486
+
487
+ Credits are scoped under the Tapi API key owner, app id, and
488
+ `user.externalUserId`. Use the same `externalUserId` for quote, balance,
489
+ checkout, and run calls. If you omit `externalUserId` but provide `email`, Tapi
490
+ uses the normalized email as the external user id.
491
+
492
+ Applications can also create a top-up checkout explicitly:
493
+
494
+ ```ts
495
+ const user = {
496
+ externalUserId: appUser.id,
497
+ email: appUser.email,
498
+ };
499
+
500
+ const balance = await tapi.cloudRuns.balance(user);
501
+
502
+ if (balance.balanceCents < balance.minimumBalanceCents) {
503
+ const checkout = await tapi.cloudRuns.checkout({
504
+ amountCents: balance.minimumBalanceCents,
505
+ user,
506
+ successUrl: "https://your-app.example/cloud/success",
507
+ cancelUrl: "https://your-app.example/cloud/cancel",
508
+ });
509
+ console.log(checkout.checkoutUrl);
510
+ }
511
+ ```
512
+
513
+ The developer application does not need VM provisioning logic, installer
514
+ deployment logic, worker command interpretation, browser runner internals, or
515
+ builder logic. Those stay behind the Tapi server API.
516
+
517
+ Website API requests are addressed as `<namespace>.<operation>`. The namespace
518
+ comes from the generated website name in Studio. The operation is the name the
519
+ developer types into the SDK name textbox at the workflow boundary.
420
520
 
421
521
  `describe()` returns the public SDK contract: input controls such as
422
522
  `textbox`, `radio`, `select`, `checkbox`, conditional requirements such as
@@ -446,6 +546,7 @@ The package includes generated TypeScript declarations. Common exported types in
446
546
 
447
547
  ```ts
448
548
  import type {
549
+ LateInput,
449
550
  RuntimeRequirements,
450
551
  RuntimeProfile,
451
552
  RuntimeRunOptions,