@layers/amba-mcp 4.0.3 → 4.0.5

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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { z } from "zod";
6
- import { INTEGRATION_PROVIDERS } from "@layers/amba-shared";
6
+ import { INTEGRATION_PROVIDERS, integrationConfigSummary } from "@layers/amba-shared";
7
7
  //#region src/auth.ts
8
8
  const CREDENTIALS_PATH = join(homedir(), ".amba", "credentials.json");
9
9
  async function loadCredentials() {
@@ -308,7 +308,9 @@ const READ_VERBS = new Set([
308
308
  "tiers",
309
309
  "verify",
310
310
  "logs",
311
- "query"
311
+ "query",
312
+ "aggregate",
313
+ "results"
312
314
  ]);
313
315
  /**
314
316
  * Destructive verbs. A tool whose name contains one of these MAY perform
@@ -368,7 +370,8 @@ const WRITE_VERBS = new Set([
368
370
  "purchase",
369
371
  "signup",
370
372
  "login",
371
- "refresh"
373
+ "refresh",
374
+ "track"
372
375
  ]);
373
376
  /**
374
377
  * Split a tool name into lowercase verb-candidate tokens, dropping the
@@ -384,7 +387,17 @@ function toolNameTokens(name) {
384
387
  * test suite asserts it doesn't, so a `null` at runtime means a new tool
385
388
  * used an unrecognised verb and needs a verb-set entry here.
386
389
  */
390
+ /**
391
+ * Per-tool classification overrides for names whose verb token is a noun
392
+ * elsewhere. `catalog` is a NOUN in catalog-management tools
393
+ * (`amba_create_catalog_item`, `amba_set_item_price`), so it can't be a global
394
+ * read verb — but `amba_events_catalog` is a pure read/discovery tool. Override
395
+ * it explicitly rather than polluting the verb sets.
396
+ */
397
+ const VERB_CLASS_OVERRIDES = { amba_events_catalog: "read" };
387
398
  function classifyToolVerb(name) {
399
+ const override = VERB_CLASS_OVERRIDES[name];
400
+ if (override) return override;
388
401
  const tokens = toolNameTokens(name);
389
402
  let sawRead = false;
390
403
  let sawWrite = false;
@@ -611,7 +624,7 @@ function registerPublicTool(server, name, description, schema, handler, aliases
611
624
  }
612
625
  //#endregion
613
626
  //#region src/tools/projects.ts
614
- function registerTools$29(server, apiClient) {
627
+ function registerTools$35(server, apiClient) {
615
628
  registerTool(server, apiClient, "amba_projects_list", "List all Amba projects owned by the authenticated developer. Returns project id, name, bundle_id, platform, and environment.", {}, async (_, { client }) => {
616
629
  const result = await client.get("/projects");
617
630
  return { content: [{
@@ -629,7 +642,7 @@ function registerTools$29(server, apiClient) {
629
642
  registerTool(server, apiClient, "amba_projects_create", "Create a new Amba project. A project represents a mobile app and contains all its engagement configuration (push, segments, config, content, streaks).", {
630
643
  name: z.string().describe("Human-readable project name (e.g. \"My Fitness App\")"),
631
644
  bundle_id: z.string().optional().describe("App bundle identifier (e.g. \"com.example.myapp\"). Doubles as the audience the server expects on Apple Sign In identity tokens — set this if your app uses Sign in with Apple, or sign-in will reject with AUDIENCE_NOT_CONFIGURED."),
632
- google_oauth_client_id: z.string().optional().describe("Google OAuth 2.0 client id from Google Cloud Console. Doubles as the audience the server expects on Google Sign In id tokens — set this if your app uses Sign in with Google, or sign-in will reject with AUDIENCE_NOT_CONFIGURED. The client id is a public identifier (not a secret) — same value that ships in the app binary."),
645
+ google_oauth_client_id: z.string().optional().describe("Google OAuth 2.0 client id from your Google OAuth configuration. Doubles as the audience the server expects on Google Sign In id tokens — set this if your app uses Sign in with Google, or sign-in will reject with AUDIENCE_NOT_CONFIGURED. The client id is a public identifier (not a secret) — same value that ships in the app binary."),
633
646
  platform: z.enum([
634
647
  "ios",
635
648
  "android",
@@ -718,7 +731,7 @@ function registerTools$29(server, apiClient) {
718
731
  }
719
732
  //#endregion
720
733
  //#region src/tools/push.ts
721
- function registerTools$28(server, apiClient) {
734
+ function registerTools$34(server, apiClient) {
722
735
  registerTool(server, apiClient, "amba_push_campaigns_create", "Create a new push notification campaign for a project. The campaign starts in \"draft\" status. You can optionally target a segment and schedule delivery.", {
723
736
  project_id: z.string().describe("The project ID"),
724
737
  title: z.string().describe("Push notification title shown to the user"),
@@ -848,7 +861,7 @@ const segmentRulesSchema = z.object({
848
861
  operator: z.enum(["AND", "OR"]).describe("Logical operator combining conditions"),
849
862
  conditions: z.array(segmentConditionSchema).describe("Array of filter conditions")
850
863
  });
851
- function registerTools$27(server, apiClient) {
864
+ function registerTools$33(server, apiClient) {
852
865
  registerTool(server, apiClient, "amba_segments_list", "List user segments for a project. Segments define groups of users based on rules (e.g. \"active in last 7 days\", \"premium users\"). Returns only project-custom segments by default. Pass include_system: true to also include the built-in system segments (e.g. \"all\", \"active_7d\", \"premium\").", {
853
866
  project_id: z.string().describe("The project ID"),
854
867
  include_system: z.boolean().optional().describe("Include built-in system segments in the result. Defaults to false.")
@@ -933,7 +946,7 @@ const configConditionSchema = z.object({
933
946
  percentage: z.number().optional().describe("Percentage rollout (0-100)"),
934
947
  value: z.unknown().describe("Override value for this condition")
935
948
  });
936
- function registerTools$26(server, apiClient) {
949
+ function registerTools$32(server, apiClient) {
937
950
  registerTool(server, apiClient, "amba_remote_configs_list", "List remote-config keys for a project. Returns key, value, value_type, description, conditions, and version per active row. Remote config lets you change app behavior without deploying an update. Returns only project-created config keys by default. Pass include_system: true to also include platform-provided defaults (e.g. app_version_min, maintenance_mode).", {
938
951
  project_id: z.string().describe("The project ID"),
939
952
  include_system: z.boolean().optional().describe("Include platform-provided default config keys in the result. Defaults to false.")
@@ -1011,8 +1024,8 @@ const contentItemSchema = z.object({
1011
1024
  metadata: z.record(z.unknown()).optional().describe("Arbitrary metadata key-value pairs"),
1012
1025
  is_premium: z.boolean().optional().describe("Whether this content requires a premium entitlement")
1013
1026
  });
1014
- function registerTools$25(server, apiClient) {
1015
- registerTool(server, apiClient, "amba_content_libraries_create", "Create a content library for a project. A content library is a collection of content items (tips, quotes, articles) that can be delivered on a schedule to keep users engaged.", {
1027
+ function registerTools$31(server, apiClient) {
1028
+ registerTool(server, apiClient, "amba_content_libraries_create", "Create a content library for a project. A content library is a collection of content items (tips, quotes, articles) that can be delivered on a schedule to keep users engaged. The library `name` IS the channel handle the in-app SDK uses: `Amba.content.library(name)` reads this library (names are unique). The response echoes it as `channel`.", {
1016
1029
  project_id: z.string().describe("The project ID"),
1017
1030
  name: z.string().describe("Library name (e.g. \"Daily Motivation\", \"Workout Tips\")"),
1018
1031
  description: z.string().optional().describe("Description of the library content"),
@@ -1027,6 +1040,17 @@ function registerTools$25(server, apiClient) {
1027
1040
  text: JSON.stringify(result, null, 2)
1028
1041
  }] };
1029
1042
  }, ["amba_create_content_library"]);
1043
+ registerTool(server, apiClient, "amba_content_libraries_delete", "Delete a content library. A library with items is refused unless cascade=true, which deletes the library and ALL its items, schedules, and deliveries in one call (irreversible). Use this to clean up orphan libraries instead of deleting items one at a time.", {
1044
+ project_id: z.string().describe("The project ID"),
1045
+ library_id: z.string().describe("The content library ID to delete"),
1046
+ cascade: z.boolean().optional().describe("Required to delete a non-empty library. When true, deletes the library and all its items, schedules, and deliveries. Defaults to false (a non-empty library returns 409 LIBRARY_NOT_EMPTY).")
1047
+ }, async ({ project_id, library_id, cascade }, { client }) => {
1048
+ const result = await client.delete(`/projects/${project_id}/content/libraries/${library_id}`, cascade ? { cascade: "true" } : void 0);
1049
+ return { content: [{
1050
+ type: "text",
1051
+ text: JSON.stringify(result, null, 2)
1052
+ }] };
1053
+ });
1030
1054
  registerTool(server, apiClient, "amba_content_items_add", "Add one or more content items to an existing content library. Items can include text, media URLs, categories, tags, and metadata.", {
1031
1055
  project_id: z.string().describe("The project ID"),
1032
1056
  library_id: z.string().describe("The content library ID"),
@@ -1056,21 +1080,23 @@ function registerTools$25(server, apiClient) {
1056
1080
  "random",
1057
1081
  "sequential"
1058
1082
  ]).describe("How content items are selected for delivery"),
1059
- config: z.record(z.unknown()).optional().describe("Schedule-specific configuration (e.g. delivery time, timezone)")
1060
- }, async ({ project_id, library_id, name, schedule_type, config }, { client }) => {
1083
+ config: z.record(z.unknown()).optional().describe("Schedule-specific configuration (e.g. delivery time, timezone)"),
1084
+ batch_size: z.number().int().min(1).max(100).optional().describe("How many items to publish per interval. Defaults to 1 (one item per run). Set to N to publish N items each interval — e.g. \"today's 3 tips\" — without hand-rolling a date-wrap rotation client-side. The selected items are delivered as an array the in-app SDK reads.")
1085
+ }, async ({ project_id, library_id, name, schedule_type, config, batch_size }, { client }) => {
1061
1086
  const payload = {
1062
1087
  library_id,
1063
1088
  name,
1064
1089
  schedule_type
1065
1090
  };
1066
1091
  if (config !== void 0) payload.config = config;
1092
+ if (batch_size !== void 0) payload.batch_size = batch_size;
1067
1093
  const result = await client.post(`/projects/${project_id}/content/schedules`, payload);
1068
1094
  return { content: [{
1069
1095
  type: "text",
1070
1096
  text: JSON.stringify(result, null, 2)
1071
1097
  }] };
1072
1098
  }, ["amba_create_content_schedule"]);
1073
- registerTool(server, apiClient, "amba_content_list_libraries", "List content libraries for a project.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
1099
+ registerTool(server, apiClient, "amba_content_list_libraries", "List content libraries for a project. Each library exposes `channel` (= name) — the handle the in-app SDK passes to `Amba.content.library(channel)` (and `Amba.content.today(scheduleName)` for scheduled rotations).", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
1074
1100
  const result = await client.get(`/projects/${project_id}/content/libraries`);
1075
1101
  return { content: [{
1076
1102
  type: "text",
@@ -1137,14 +1163,16 @@ function registerTools$25(server, apiClient) {
1137
1163
  cron: z.string().optional().describe("Cron expression (5-field) for delivery timing"),
1138
1164
  timezone: z.string().optional().describe("IANA timezone (e.g. \"America/Los_Angeles\")"),
1139
1165
  segment_id: z.string().nullable().optional().describe("Target segment, or null to broadcast to all users"),
1140
- is_active: z.boolean().optional().describe("Whether the schedule is active")
1141
- }, async ({ project_id, schedule_id, name, cron, timezone, segment_id, is_active }, { client }) => {
1166
+ is_active: z.boolean().optional().describe("Whether the schedule is active"),
1167
+ batch_size: z.number().int().min(1).max(100).optional().describe("How many items to publish per interval (1–100). Set to N to switch this schedule to publishing N items each run instead of one.")
1168
+ }, async ({ project_id, schedule_id, name, cron, timezone, segment_id, is_active, batch_size }, { client }) => {
1142
1169
  const payload = {};
1143
1170
  if (name !== void 0) payload.name = name;
1144
1171
  if (cron !== void 0) payload.cron = cron;
1145
1172
  if (timezone !== void 0) payload.timezone = timezone;
1146
1173
  if (segment_id !== void 0) payload.segment_id = segment_id;
1147
1174
  if (is_active !== void 0) payload.is_active = is_active;
1175
+ if (batch_size !== void 0) payload.batch_size = batch_size;
1148
1176
  const result = await client.patch(`/projects/${project_id}/content/schedules/${schedule_id}`, payload);
1149
1177
  return { content: [{
1150
1178
  type: "text",
@@ -1175,7 +1203,7 @@ function registerTools$25(server, apiClient) {
1175
1203
  }
1176
1204
  //#endregion
1177
1205
  //#region src/tools/streaks.ts
1178
- function registerTools$24(server, apiClient) {
1206
+ function registerTools$30(server, apiClient) {
1179
1207
  registerTool(server, apiClient, "amba_streaks_create", "Create a streak definition for a project. Streaks track consecutive user engagement (e.g. daily logins, workout completions). Supports configurable periods, grace periods, and freeze mechanics.", {
1180
1208
  project_id: z.string().describe("The project ID"),
1181
1209
  key: z.string().regex(/^[a-z0-9_-]{1,64}$/).optional().describe("Stable identifier used by the SDK to qualify a streak without knowing its UUID: `Amba.streaks.qualify(\"daily_login\")`. Lowercase letters, digits, underscore, hyphen, 1-64 characters. Immutable after creation — changing it breaks live SDK calls. When omitted the server derives one from `name`."),
@@ -1267,11 +1295,11 @@ function registerTools$24(server, apiClient) {
1267
1295
  //#endregion
1268
1296
  //#region src/tools/integrations.ts
1269
1297
  const providerEnum = z.enum(INTEGRATION_PROVIDERS);
1270
- function registerTools$23(server, apiClient) {
1271
- registerTool(server, apiClient, "amba_integrations_configure", "Configure (create or replace) a third-party integration for a project. Supported providers: \"apns\" (Apple Push), \"fcm\" (Firebase Cloud Messaging), \"revenuecat\" (subscription management), \"superwall\" (paywall management). Each provider requires specific config fields.", {
1298
+ function registerTools$29(server, apiClient) {
1299
+ registerTool(server, apiClient, "amba_integrations_configure", "Configure (create or replace) a third-party integration for a project. Supported providers: \"apns\" (Apple Push), \"fcm\" (Firebase Cloud Messaging), \"revenuecat\" (subscription management), \"superwall\" (paywall management). Each provider requires specific config fields. Push credentials (.p8 / service-account JSON) are passed inline here and stored server-side automatically — no separate secrets step.", {
1272
1300
  project_id: z.string().describe("The project ID"),
1273
1301
  provider: providerEnum.describe("Integration provider name"),
1274
- config: z.record(z.unknown()).describe("Provider-specific configuration. For APNs: { key_id, team_id, bundle_id, key_p8 }. For FCM: { service_account_json }. For RevenueCat: { api_key, webhook_secret }. For Superwall: { api_key }.")
1302
+ config: z.record(z.unknown()).describe("Provider-specific configuration. " + integrationConfigSummary())
1275
1303
  }, async ({ project_id, provider, config }, { client }) => {
1276
1304
  const result = await client.post(`/projects/${project_id}/integrations`, {
1277
1305
  provider,
@@ -1337,7 +1365,7 @@ const PERIOD_DAYS = {
1337
1365
  "30d": "30",
1338
1366
  "90d": "90"
1339
1367
  };
1340
- function registerTools$22(server, apiClient) {
1368
+ function registerTools$28(server, apiClient) {
1341
1369
  registerTool(server, apiClient, "amba_analytics_get", "Get daily analytics for a project. Returns per-day event counts and unique-user counts for the requested window, plus totals. Use this to verify that end-to-end event tracking is working after seeding and testing.", {
1342
1370
  project_id: z.string().describe("The project ID"),
1343
1371
  period: z.enum([
@@ -1359,8 +1387,58 @@ function registerTools$22(server, apiClient) {
1359
1387
  }, ["amba_get_analytics"]);
1360
1388
  }
1361
1389
  //#endregion
1390
+ //#region src/lib/tool-result.ts
1391
+ /**
1392
+ * Shared MCP tool result helpers.
1393
+ *
1394
+ * Every tool emits the same `{ content: [{ type: 'text', text: <json> }] }`
1395
+ * envelope. Two helpers centralize that:
1396
+ *
1397
+ * - [`jsonResult`] — wraps an arbitrary payload.
1398
+ * - [`passthroughResult`] — flattens an upstream HTTP response
1399
+ * (status + parsed body) into the same envelope. Status is written
1400
+ * LAST so a colliding top-level `status` field in the API response
1401
+ * cannot shadow the HTTP status — agents look at `parsed.status` to
1402
+ * distinguish 2xx from 4xx/5xx.
1403
+ *
1404
+ * Lives in `src/lib/` (vs. `src/tools/_helpers.ts`) to set the same
1405
+ * cross-cutting-helper precedent as `src/lib/with-pat.ts` (task #36).
1406
+ * `tools/*` files stay strictly tool registrations.
1407
+ */
1408
+ /**
1409
+ * Wire-shape every MCP tool handler returns.
1410
+ *
1411
+ * The MCP SDK's `tool()` callback signature is structurally typed and
1412
+ * carries an open index signature for `_meta` etc. Declaring our return
1413
+ * type as a plain `{ content: [...] }` interface won't satisfy that
1414
+ * structural check — so the helpers' return type is left as the actual
1415
+ * inferred shape (no explicit interface) and consumers rely on the
1416
+ * inference + the SDK's structural compatibility. If we ever want a
1417
+ * named alias, write it as a type-alias over the inferred shape rather
1418
+ * than a closed interface.
1419
+ */
1420
+ /** Wrap an arbitrary payload as a JSON-text tool result. */
1421
+ function jsonResult$1(payload) {
1422
+ return { content: [{
1423
+ type: "text",
1424
+ text: JSON.stringify(payload, null, 2)
1425
+ }] };
1426
+ }
1427
+ /**
1428
+ * Flatten an upstream HTTP response into the agent-facing tool payload.
1429
+ * Body fields are spread FIRST so a future top-level `status` key in
1430
+ * the API response cannot shadow the HTTP `status` — agents read
1431
+ * `parsed.status` to distinguish 2xx from 4xx/5xx.
1432
+ */
1433
+ function passthroughResult(result) {
1434
+ return jsonResult$1({
1435
+ ...result.body ?? {},
1436
+ status: result.status
1437
+ });
1438
+ }
1439
+ //#endregion
1362
1440
  //#region src/tools/users.ts
1363
- function registerTools$21(server, apiClient) {
1441
+ function registerTools$27(server, apiClient) {
1364
1442
  registerTool(server, apiClient, "amba_users_list", "List app users for a project. Supports pagination with offset and limit, plus optional search (matches email / external_id / display_name with ILIKE) and segment filter. Returns user profiles including external_id, email, display_name, properties, and activity timestamps.", {
1365
1443
  project_id: z.string().describe("The project ID"),
1366
1444
  limit: z.number().optional().describe("Maximum number of users to return (default 50)"),
@@ -1379,6 +1457,30 @@ function registerTools$21(server, apiClient) {
1379
1457
  text: JSON.stringify(result, null, 2)
1380
1458
  }] };
1381
1459
  }, ["amba_list_users"]);
1460
+ registerTool(server, apiClient, "amba_users_create", "Admin-create an app user from the developer/agent side. Use this to mint a system/seed user to attribute migrated rows to — e.g. after amba_users_reset_sandbox wiped everyone. All fields are optional; with no fields it mints an anonymous user. Collisions on external_id/anonymous_id/email return 409 USER_ALREADY_EXISTS.", {
1461
+ project_id: z.string().describe("The project ID"),
1462
+ external_id: z.string().optional().describe("Your stable external identifier for this user (unique). Optional."),
1463
+ anonymous_id: z.string().optional().describe("Anonymous device id (unique). Optional; auto-generated if no identity given."),
1464
+ email: z.string().optional().describe("Email address. Optional."),
1465
+ phone: z.string().optional().describe("Phone number. Optional."),
1466
+ display_name: z.string().optional().describe("Display name. Optional."),
1467
+ avatar_url: z.string().optional().describe("Avatar URL. Optional."),
1468
+ properties: z.record(z.unknown()).optional().describe("Arbitrary user properties (JSON object). Optional.")
1469
+ }, async ({ project_id, external_id, anonymous_id, email, phone, display_name, avatar_url, properties }, { client }) => {
1470
+ const payload = {};
1471
+ if (external_id !== void 0) payload.external_id = external_id;
1472
+ if (anonymous_id !== void 0) payload.anonymous_id = anonymous_id;
1473
+ if (email !== void 0) payload.email = email;
1474
+ if (phone !== void 0) payload.phone = phone;
1475
+ if (display_name !== void 0) payload.display_name = display_name;
1476
+ if (avatar_url !== void 0) payload.avatar_url = avatar_url;
1477
+ if (properties !== void 0) payload.properties = properties;
1478
+ const result = await client.post(`/projects/${project_id}/users`, payload);
1479
+ return { content: [{
1480
+ type: "text",
1481
+ text: JSON.stringify(result, null, 2)
1482
+ }] };
1483
+ }, ["amba_create_user"]);
1382
1484
  registerTool(server, apiClient, "amba_users_get", "Get a single app user by id, with their streaks and entitlements joined in.", {
1383
1485
  project_id: z.string().describe("The project ID"),
1384
1486
  user_id: z.string().describe("The app user ID")
@@ -1455,6 +1557,23 @@ function registerTools$21(server, apiClient) {
1455
1557
  text: JSON.stringify(result, null, 2)
1456
1558
  }] };
1457
1559
  });
1560
+ registerTool(server, apiClient, "amba_users_create_cohort", [
1561
+ "Mint N anonymous app_users at once, all sharing the same properties (and, optionally, the same segment memberships) in a single call.",
1562
+ "The seed-a-test-cohort primitive: use it to populate a leaderboard, dry-run a push segment, or stand up a batch of users tagged `cohort: beta`.",
1563
+ "Each user is anonymous-style (no external identity) — a shared-properties cohort has no per-user identity by definition. Pass `segment_ids` to enrol every minted user into those segments.",
1564
+ "Capped at 500 per call (split larger seeds into multiple calls). Returns `{ user_ids, created_count }`.",
1565
+ "Errors: 400 INVALID_COUNT (out of 1–500) / INVALID_SEGMENT_ID (segment does not exist), 500 COHORT_FAILED."
1566
+ ].join(" "), {
1567
+ project_id: z.string().describe("The project ID."),
1568
+ count: z.number().int().min(1).max(500).describe("How many users to mint (1–500). Larger counts return INVALID_COUNT."),
1569
+ properties: z.record(z.unknown()).optional().describe("Properties stamped identically onto every minted user (JSON object). Optional."),
1570
+ segment_ids: z.array(z.string()).optional().describe("Segment UUIDs every minted user is enrolled into. Optional.")
1571
+ }, async ({ project_id, count, properties, segment_ids }, { client }) => {
1572
+ const payload = { count };
1573
+ if (properties !== void 0) payload.properties = properties;
1574
+ if (segment_ids !== void 0) payload.segment_ids = segment_ids;
1575
+ return jsonResult$1(await client.post(`/projects/${project_id}/users/cohort`, payload));
1576
+ });
1458
1577
  registerTool(server, apiClient, "amba_users_delete", [
1459
1578
  "Hard-delete a single app_user. Pass exactly one of `user_id`",
1460
1579
  "(uuid) or `anonymous_id` (string). Returns the deleted id plus",
@@ -1525,6 +1644,31 @@ function registerTools$21(server, apiClient) {
1525
1644
  text: JSON.stringify(result, null, 2)
1526
1645
  }] };
1527
1646
  });
1647
+ registerTool(server, apiClient, "amba_users_reset_user", [
1648
+ "Reset ONE app user to a brand-new state WITHOUT deleting them. Clears",
1649
+ "that single user's gamification (XP + ledger, achievements,",
1650
+ "leaderboard entries, challenges), economy (currency balances +",
1651
+ "transactions, inventory, entitlements), streaks, engagement/telemetry",
1652
+ "events, sessions, onboarding, roles, segment memberships, push tokens",
1653
+ "and their social participation (friendships, group + conversation",
1654
+ "memberships, messages, reviews, feed activity) — but keeps the",
1655
+ "app_users row itself (email, anonymous_id, properties survive). This",
1656
+ "is the scalpel to amba_users_reset_sandbox’s sledgehammer: reset a",
1657
+ "single stuck/test user on a production project without touching a",
1658
+ "seeded cohort. Developer-authored config (currencies, achievement",
1659
+ "definitions, catalog) is never touched — the user goes to zero, the",
1660
+ "project does not change. Pass `delete:true` to ALSO remove the",
1661
+ "app_users row at the end (equivalent to amba_users_delete). Returns a",
1662
+ "per-table summary of what was cleared. 404 USER_NOT_FOUND if no row",
1663
+ "matched."
1664
+ ].join(" "), {
1665
+ project_id: z.string().describe("The project ID"),
1666
+ user_id: z.string().describe("The app user UUID to reset."),
1667
+ delete: z.boolean().optional().describe("When true, also delete the app_users row after clearing its state (full removal). Defaults to false — the user is kept and only reset.")
1668
+ }, async ({ project_id, user_id, delete: alsoDelete }, { client }) => {
1669
+ const query = alsoDelete ? { delete: "true" } : void 0;
1670
+ return jsonResult$1(await client.delete(`/projects/${project_id}/users/${user_id}/reset`, query));
1671
+ });
1528
1672
  registerTool(server, apiClient, "amba_sessions_analytics", "Get session analytics (DAU, active users, total sessions, average and median session duration) for a project across a rolling window.", {
1529
1673
  project_id: z.string().describe("The project ID"),
1530
1674
  period: z.enum([
@@ -1638,12 +1782,18 @@ println("Current streak: \${streak.currentCount}")`,
1638
1782
  // For Expo apps, use @layers/amba-expo instead.
1639
1783
  import { Amba } from '@layers/amba-react-native';
1640
1784
 
1641
- // Configure once at app startup
1785
+ // Configure once at app startup. The first authenticated call lazily
1786
+ // establishes an anonymous session, so reads work without a manual sign-in.
1787
+ // Call signInAnonymously() explicitly if you want to control when the
1788
+ // anonymous user is created.
1642
1789
  await Amba.configure({
1643
1790
  projectId: 'YOUR_PROJECT_ID',
1644
- apiKey: 'YOUR_CLIENT_API_KEY',
1791
+ clientKey: 'YOUR_CLIENT_API_KEY',
1645
1792
  });
1646
1793
 
1794
+ // Optional — establish the session up front instead of lazily:
1795
+ // await Amba.auth.signInAnonymously();
1796
+
1647
1797
  // Track events
1648
1798
  await Amba.events.track('workout_completed', {
1649
1799
  duration_minutes: 30,
@@ -1665,9 +1815,11 @@ import { Amba } from '@layers/amba-expo';
1665
1815
 
1666
1816
  export default function RootLayout() {
1667
1817
  useEffect(() => {
1668
- Amba.configure({
1818
+ // configure() is async. Reads lazily establish an anonymous session on the
1819
+ // first authenticated call, so no manual sign-in is required to start.
1820
+ void Amba.configure({
1669
1821
  projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
1670
- apiKey: 'YOUR_CLIENT_API_KEY',
1822
+ clientKey: 'YOUR_CLIENT_API_KEY',
1671
1823
  });
1672
1824
  }, []);
1673
1825
 
@@ -1733,7 +1885,7 @@ final dailyLimit = await Amba.config.get<int>('daily_limit', defaultValue: 10);
1733
1885
  final streak = await Amba.streaks.get('daily_login');
1734
1886
  print('Current streak: \${streak.currentCount}');`
1735
1887
  };
1736
- function registerTools$20(server, apiClient) {
1888
+ function registerTools$26(server, apiClient) {
1737
1889
  registerTool(server, apiClient, "amba_sdk_get_setup_instructions", "Get platform-specific code snippets and setup instructions for integrating the Amba client SDK into a mobile app. Supports iOS (Swift), Android (Kotlin), React Native, Expo, and Flutter.", { platform: z.enum([
1738
1890
  "ios",
1739
1891
  "android",
@@ -1818,7 +1970,7 @@ const criteriaSchema = z.object({
1818
1970
  property_key: z.string().optional().describe("User property key (required for property_value type)"),
1819
1971
  target_value: z.number().describe("Target value to unlock the achievement")
1820
1972
  });
1821
- function registerTools$19(server, apiClient) {
1973
+ function registerTools$25(server, apiClient) {
1822
1974
  registerTool(server, apiClient, "amba_achievements_create", "Create an achievement/badge definition. Achievements unlock automatically when users meet the criteria (e.g. track 5 workouts, reach a 7-day streak, earn 5000 XP). Can optionally award bonus XP on unlock.", {
1823
1975
  project_id: z.string().describe("The project ID"),
1824
1976
  key: z.string().describe("Unique key for this achievement (e.g. \"first_workout\", \"streak_master_7\")"),
@@ -1923,7 +2075,7 @@ function registerTools$19(server, apiClient) {
1923
2075
  }
1924
2076
  //#endregion
1925
2077
  //#region src/tools/challenges.ts
1926
- function registerTools$18(server, apiClient) {
2078
+ function registerTools$24(server, apiClient) {
1927
2079
  registerTool(server, apiClient, "amba_challenges_create", "Create a time-limited challenge. Challenges are events that run between start_at and end_at, where users try to reach a goal (e.g. track 10 workouts this week, earn 500 XP in 3 days). Can reward XP and/or unlock an achievement on completion.", {
1928
2080
  project_id: z.string().describe("The project ID"),
1929
2081
  name: z.string().describe("Challenge name (e.g. \"7-Day Fitness Sprint\", \"XP Weekend Blitz\")"),
@@ -2034,7 +2186,7 @@ function registerTools$18(server, apiClient) {
2034
2186
  }
2035
2187
  //#endregion
2036
2188
  //#region src/tools/economy.ts
2037
- function registerTools$17(server, apiClient) {
2189
+ function registerTools$23(server, apiClient) {
2038
2190
  registerTool(server, apiClient, "amba_currencies_create", "Create a virtual currency for a project. Currencies can be soft (earned through gameplay) or premium (purchased with real money). Supports auto-recharge for time-gated mechanics like hearts/energy.", {
2039
2191
  project_id: z.string().describe("The project ID"),
2040
2192
  code: z.string().describe("Unique currency code (e.g. \"gold\", \"gems\", \"hearts\")"),
@@ -2069,38 +2221,42 @@ function registerTools$17(server, apiClient) {
2069
2221
  text: JSON.stringify(result, null, 2)
2070
2222
  }] };
2071
2223
  }, ["amba_list_currencies"]);
2072
- registerTool(server, apiClient, "amba_currencies_grant", "Grant virtual currency to a specific user. Useful for rewards, promotions, or admin adjustments.", {
2224
+ registerTool(server, apiClient, "amba_currencies_grant", "Grant virtual currency to a specific user. Useful for rewards, promotions, or admin adjustments. Pass idempotency_key to make a grant exactly-once: a repeat with the same key for the same (user, currency) returns the original transaction (HTTP 200, idempotent_replay:true) without re-applying — e.g. encode `share:{userId}:{yyyy-mm-dd}` to cap \"one per share per day\" without a guard collection.", {
2073
2225
  project_id: z.string().describe("The project ID"),
2074
2226
  app_user_id: z.string().describe("The user to grant currency to"),
2075
2227
  currency_code: z.string().describe("Currency code (e.g. \"gold\")"),
2076
2228
  amount: z.number().describe("Amount to grant (positive integer)"),
2077
- reason: z.string().optional().describe("Reason for the grant (for audit trail)")
2078
- }, async ({ project_id, app_user_id, currency_code, amount, reason }, { client }) => {
2229
+ reason: z.string().optional().describe("Reason for the grant (for audit trail)"),
2230
+ idempotency_key: z.string().optional().describe("Dedup key (≤255 chars). A repeat with the same key for this (user, currency) returns the original grant without re-applying. The key is permanent — encode the window into it (e.g. include the date for once-per-day).")
2231
+ }, async ({ project_id, app_user_id, currency_code, amount, reason, idempotency_key }, { client }) => {
2079
2232
  const payload = {
2080
2233
  app_user_id,
2081
2234
  currency_code,
2082
2235
  amount
2083
2236
  };
2084
2237
  if (reason !== void 0) payload.reason = reason;
2238
+ if (idempotency_key !== void 0) payload.idempotency_key = idempotency_key;
2085
2239
  const result = await client.post(`/projects/${project_id}/currencies/grant`, payload);
2086
2240
  return { content: [{
2087
2241
  type: "text",
2088
2242
  text: JSON.stringify(result, null, 2)
2089
2243
  }] };
2090
2244
  }, ["amba_grant_currency"]);
2091
- registerTool(server, apiClient, "amba_currencies_spend", "Debit virtual currency from a specific user. Atomic — returns 400 INSUFFICIENT_FUNDS if the balance is too low (no partial debit). Use for in-app purchases priced in soft currency, gameplay consumes, or admin adjustments. The amount parameter is positive; the ledger records the spend as a negative delta automatically.", {
2245
+ registerTool(server, apiClient, "amba_currencies_spend", "Debit virtual currency from a specific user. Atomic — returns 400 INSUFFICIENT_FUNDS if the balance is too low (no partial debit). Use for in-app purchases priced in soft currency, gameplay consumes, or admin adjustments. The amount parameter is positive; the ledger records the spend as a negative delta automatically. Pass idempotency_key to make a debit exactly-once: a repeat with the same key returns the original debit (HTTP 200, idempotent_replay:true) without double-charging.", {
2092
2246
  project_id: z.string().describe("The project ID"),
2093
2247
  app_user_id: z.string().describe("The user to debit currency from"),
2094
2248
  currency_code: z.string().describe("Currency code (e.g. \"gold\")"),
2095
2249
  amount: z.number().describe("Positive amount to debit. The endpoint records this as a negative delta on the ledger."),
2096
- reason: z.string().optional().describe("Reason for the spend (stored as reference_id, useful for the audit trail)")
2097
- }, async ({ project_id, app_user_id, currency_code, amount, reason }, { client }) => {
2250
+ reason: z.string().optional().describe("Reason for the spend (stored as reference_id, useful for the audit trail)"),
2251
+ idempotency_key: z.string().optional().describe("Dedup key (≤255 chars). A repeat with the same key for this (user, currency) returns the original debit without double-charging. A spend that failed with INSUFFICIENT_FUNDS stores no key, so retrying it is allowed.")
2252
+ }, async ({ project_id, app_user_id, currency_code, amount, reason, idempotency_key }, { client }) => {
2098
2253
  const payload = {
2099
2254
  app_user_id,
2100
2255
  currency_code,
2101
2256
  amount
2102
2257
  };
2103
2258
  if (reason !== void 0) payload.reason = reason;
2259
+ if (idempotency_key !== void 0) payload.idempotency_key = idempotency_key;
2104
2260
  const result = await client.post(`/projects/${project_id}/currencies/spend`, payload);
2105
2261
  return { content: [{
2106
2262
  type: "text",
@@ -2278,6 +2434,44 @@ function registerTools$17(server, apiClient) {
2278
2434
  text: JSON.stringify(result, null, 2)
2279
2435
  }] };
2280
2436
  });
2437
+ registerTool(server, apiClient, "amba_entitlements_grant", [
2438
+ "Grant or refresh a subscription entitlement directly on a user — the same `user_entitlements` row mobile store webhooks (Apple/Play) write to, so a server-side grant and an in-app purchase land in one unified place your client SDK reads with `Amba.entitlements`.",
2439
+ "Covers the non-mobile cases that have no webhook: Stripe/Paddle web checkout, manual support grants, gift codes, legacy migrations. Set is_active=false to revoke (ToS violations, refunds).",
2440
+ "Upserts on (user, entitlement_id) — a second call for the same pair refreshes the existing row in place rather than duplicating. Omit a nullable field to preserve it; pass null to clear it."
2441
+ ].join(" "), {
2442
+ project_id: z.string().describe("The project ID"),
2443
+ app_user_id: z.string().describe("The app_user UUID to grant the entitlement to"),
2444
+ entitlement_id: z.string().describe("Entitlement identifier (e.g. \"premium\", \"pro_annual\"). Required."),
2445
+ is_active: z.boolean().optional().describe("Whether the entitlement is active. Defaults to true on grant; pass false to revoke."),
2446
+ product_id: z.string().nullable().optional().describe("Store product identifier (e.g. \"stripe_premium_yr\"). Pass null to clear."),
2447
+ store: z.enum([
2448
+ "app_store",
2449
+ "play_store",
2450
+ "stripe"
2451
+ ]).nullable().optional().describe("Originating store. Pass null to clear."),
2452
+ purchase_date: z.string().nullable().optional().describe("ISO-8601 purchase timestamp. Pass null to clear."),
2453
+ expiration_date: z.string().nullable().optional().describe("ISO-8601 expiration timestamp. Pass null to clear (e.g. for a lifetime grant)."),
2454
+ period_type: z.enum([
2455
+ "trial",
2456
+ "intro",
2457
+ "normal"
2458
+ ]).nullable().optional().describe("Billing period type. Pass null to clear."),
2459
+ raw_data: z.record(z.unknown()).nullable().optional().describe("Opaque JSON blob for audit (e.g. the raw webhook payload). Pass null to reset to {}.")
2460
+ }, async ({ project_id, app_user_id, entitlement_id, is_active, product_id, store, purchase_date, expiration_date, period_type, raw_data }, { client }) => {
2461
+ const payload = { entitlement_id };
2462
+ if (is_active !== void 0) payload.is_active = is_active;
2463
+ if (product_id !== void 0) payload.product_id = product_id;
2464
+ if (store !== void 0) payload.store = store;
2465
+ if (purchase_date !== void 0) payload.purchase_date = purchase_date;
2466
+ if (expiration_date !== void 0) payload.expiration_date = expiration_date;
2467
+ if (period_type !== void 0) payload.period_type = period_type;
2468
+ if (raw_data !== void 0) payload.raw_data = raw_data;
2469
+ const result = await client.post(`/projects/${project_id}/users/${app_user_id}/entitlements`, payload);
2470
+ return { content: [{
2471
+ type: "text",
2472
+ text: JSON.stringify(result, null, 2)
2473
+ }] };
2474
+ });
2281
2475
  registerTool(server, apiClient, "amba_users_get_inventory", "View a user's inventory including all owned items and quantities.", {
2282
2476
  project_id: z.string().describe("The project ID"),
2283
2477
  app_user_id: z.string().describe("The user ID to look up")
@@ -2563,11 +2757,28 @@ function registerTools$17(server, apiClient) {
2563
2757
  text: JSON.stringify(result, null, 2)
2564
2758
  }] };
2565
2759
  }, ["amba_get_currency_transactions"]);
2760
+ registerTool(server, apiClient, "amba_currencies_get_user_balance", [
2761
+ "Read a user's current per-currency balances directly — the live value your client SDK and paywall logic see, returned in one call instead of replaying and summing the transaction ledger.",
2762
+ "Returns one row per currency the user holds (balance, lifetime_earned, lifetime_spent, last_recharged_at), or an empty array if they hold none. Pass currency_id to narrow to a single currency.",
2763
+ "Pair with amba_currencies_grant / amba_currencies_spend to verify a balance change landed, or with amba_currencies_get_transactions to reconcile the balance against its ledger history."
2764
+ ].join(" "), {
2765
+ project_id: z.string().describe("The project ID"),
2766
+ user_id: z.string().describe("The app user ID"),
2767
+ currency_id: z.string().optional().describe("Filter to one currency (definition UUID).")
2768
+ }, async ({ project_id, user_id, currency_id }, { client }) => {
2769
+ const query = {};
2770
+ if (currency_id !== void 0) query.currency_id = currency_id;
2771
+ const result = await client.get(`/projects/${project_id}/currencies/balances/${user_id}`, query);
2772
+ return { content: [{
2773
+ type: "text",
2774
+ text: JSON.stringify(result, null, 2)
2775
+ }] };
2776
+ });
2566
2777
  }
2567
2778
  //#endregion
2568
2779
  //#region src/tools/leaderboards.ts
2569
- function registerTools$16(server, apiClient) {
2570
- registerTool(server, apiClient, "amba_leaderboards_create", "Create a leaderboard definition. Leaderboards rank users by XP, streak length, or a custom metric. Supports all-time, daily, weekly, and monthly periods with configurable max entries.", {
2780
+ function registerTools$22(server, apiClient) {
2781
+ registerTool(server, apiClient, "amba_leaderboards_create", "Create a leaderboard definition. Leaderboards rank users by XP, streak length, or a custom metric. Supports all-time, daily, weekly, and monthly periods with configurable max entries. The returned `key` (which equals `name`) is the exact value the in-app SDK passes to `Amba.leaderboards.getEntries(key)` / `getRank(key)`.", {
2571
2782
  project_id: z.string().describe("The project ID"),
2572
2783
  name: z.string().describe("Leaderboard name (e.g. \"Top XP Earners\", \"Weekly Streak Leaders\")"),
2573
2784
  metric: z.enum([
@@ -2612,7 +2823,7 @@ function registerTools$16(server, apiClient) {
2612
2823
  text: JSON.stringify(result, null, 2)
2613
2824
  }] };
2614
2825
  }, ["amba_get_leaderboard"]);
2615
- registerTool(server, apiClient, "amba_leaderboards_list", "List all leaderboard definitions for a project. Returns name, metric, period, and max_entries per leaderboard. (Use `amba_leaderboards_get` to fetch ranked entries for a specific leaderboard.)", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2826
+ registerTool(server, apiClient, "amba_leaderboards_list", "List all leaderboard definitions for a project. Returns `key` (= name, the handle the in-app SDK `leaderboards.getEntries(key)` expects), name, metric, period, and max_entries per leaderboard. (Use `amba_leaderboards_get` to fetch ranked entries for a specific leaderboard.)", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2616
2827
  const result = await client.get(`/projects/${project_id}/leaderboards`);
2617
2828
  return { content: [{
2618
2829
  type: "text",
@@ -2671,8 +2882,80 @@ function registerTools$16(server, apiClient) {
2671
2882
  }, ["amba_delete_leaderboard"]);
2672
2883
  }
2673
2884
  //#endregion
2885
+ //#region src/tools/leagues.ts
2886
+ /**
2887
+ * Leagues — the Duolingo-style promote/demote tiers (Bronze/Silver/Gold/…).
2888
+ * Distinct from leaderboards (a single ranked list): a league has weekly
2889
+ * cohorts that the rollover workflow reshuffles. These tools provision and
2890
+ * inspect leagues; the admin routes are mounted at
2891
+ * `/v1/admin/projects/:projectId/leagues`. Members are assigned by the weekly
2892
+ * rollover (the scheduled weekly rollover) and score live off `xp_awarded`
2893
+ * engagement events.
2894
+ */
2895
+ function registerTools$21(server, apiClient) {
2896
+ registerTool(server, apiClient, "amba_leagues_create", "Create a league tier (e.g. \"Bronze\", tier_order=1). Leagues are the promote/demote ladder: each week users are grouped into cohorts of `cohort_size`, the top `promote_count` move up a tier and the bottom `demote_count` move down. Create one league per tier (lowest tier_order = entry tier). Members are assigned by the weekly rollover, not at create time.", {
2897
+ project_id: z.string().describe("The project ID"),
2898
+ name: z.string().describe("League tier name, e.g. \"Bronze\", \"Silver\", \"Gold\""),
2899
+ tier_order: z.number().describe("Tier rank, 1 = lowest/entry tier. Higher tiers have higher tier_order."),
2900
+ promote_count: z.number().optional().describe("How many top members of a cohort promote each week (default 5)"),
2901
+ demote_count: z.number().optional().describe("How many bottom members of a cohort demote each week (default 5)"),
2902
+ cohort_size: z.number().optional().describe("Target members per weekly cohort (default 30)")
2903
+ }, async ({ project_id, name, tier_order, promote_count, demote_count, cohort_size }, { client }) => {
2904
+ const payload = {
2905
+ name,
2906
+ tier_order
2907
+ };
2908
+ if (promote_count !== void 0) payload.promote_count = promote_count;
2909
+ if (demote_count !== void 0) payload.demote_count = demote_count;
2910
+ if (cohort_size !== void 0) payload.cohort_size = cohort_size;
2911
+ const result = await client.post(`/projects/${project_id}/leagues`, payload);
2912
+ return { content: [{
2913
+ type: "text",
2914
+ text: JSON.stringify(result, null, 2)
2915
+ }] };
2916
+ }, ["amba_create_league"]);
2917
+ registerTool(server, apiClient, "amba_leagues_list", "List all league tiers for a project, ordered by tier. Returns name, tier_order, promote_count, demote_count, and cohort_size per league.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2918
+ const result = await client.get(`/projects/${project_id}/leagues`);
2919
+ return { content: [{
2920
+ type: "text",
2921
+ text: JSON.stringify(result, null, 2)
2922
+ }] };
2923
+ }, ["amba_list_leagues"]);
2924
+ registerTool(server, apiClient, "amba_leagues_update", "Partially update a league tier. Only the supplied fields change. `tier_order` is intentionally immutable (re-ranking mid-week would require re-cohorting every active membership).", {
2925
+ project_id: z.string().describe("The project ID"),
2926
+ league_id: z.string().describe("The league ID"),
2927
+ name: z.string().optional().describe("New tier name"),
2928
+ promote_count: z.number().optional().describe("New weekly promote count"),
2929
+ demote_count: z.number().optional().describe("New weekly demote count"),
2930
+ cohort_size: z.number().optional().describe("New target cohort size"),
2931
+ is_active: z.boolean().optional().describe("Enable/disable the tier")
2932
+ }, async ({ project_id, league_id, name, promote_count, demote_count, cohort_size, is_active }, { client }) => {
2933
+ const payload = {};
2934
+ if (name !== void 0) payload.name = name;
2935
+ if (promote_count !== void 0) payload.promote_count = promote_count;
2936
+ if (demote_count !== void 0) payload.demote_count = demote_count;
2937
+ if (cohort_size !== void 0) payload.cohort_size = cohort_size;
2938
+ if (is_active !== void 0) payload.is_active = is_active;
2939
+ const result = await client.patch(`/projects/${project_id}/leagues/${league_id}`, payload);
2940
+ return { content: [{
2941
+ type: "text",
2942
+ text: JSON.stringify(result, null, 2)
2943
+ }] };
2944
+ }, ["amba_update_league"]);
2945
+ registerTool(server, apiClient, "amba_leagues_list_cohorts", "List this week's active cohorts for a league, each with a member_count. Cohorts (and their memberships) are created by the weekly rollover — a freshly created league shows no cohorts until the first rollover runs.", {
2946
+ project_id: z.string().describe("The project ID"),
2947
+ league_id: z.string().describe("The league ID")
2948
+ }, async ({ project_id, league_id }, { client }) => {
2949
+ const result = await client.get(`/projects/${project_id}/leagues/${league_id}/cohorts/current`);
2950
+ return { content: [{
2951
+ type: "text",
2952
+ text: JSON.stringify(result, null, 2)
2953
+ }] };
2954
+ }, ["amba_list_league_cohorts"]);
2955
+ }
2956
+ //#endregion
2674
2957
  //#region src/tools/platform.ts
2675
- function registerTools$15(server, apiClient) {
2958
+ function registerTools$20(server, apiClient) {
2676
2959
  registerTool(server, apiClient, "amba_onboarding_create", "Create an onboarding flow for a project. Onboarding flows define step-by-step experiences for new users (e.g. welcome screens, permission prompts, feature tours).", {
2677
2960
  project_id: z.string().describe("The project ID"),
2678
2961
  name: z.string().describe("Flow name (e.g. \"Welcome Flow\", \"Premium Onboarding\")"),
@@ -3248,7 +3531,7 @@ function registerTools$15(server, apiClient) {
3248
3531
  }
3249
3532
  //#endregion
3250
3533
  //#region src/tools/social.ts
3251
- function registerTools$14(server, apiClient) {
3534
+ function registerTools$19(server, apiClient) {
3252
3535
  registerTool(server, apiClient, "amba_friendships_get_stats", "Get friendship statistics for a project. Returns total friendships, pending requests, accepted friendships, and blocked count.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
3253
3536
  const result = await client.get(`/projects/${project_id}/friends/stats`);
3254
3537
  return { content: [{
@@ -3573,7 +3856,7 @@ function registerTools$14(server, apiClient) {
3573
3856
  }
3574
3857
  //#endregion
3575
3858
  //#region src/tools/xp.ts
3576
- function registerTools$13(server, apiClient) {
3859
+ function registerTools$18(server, apiClient) {
3577
3860
  registerTool(server, apiClient, "amba_xp_rules_create", "Create an XP rule that auto-awards XP when a matching engagement event is tracked. For example, award 50 XP every time a user completes a workout, with optional daily caps and cooldowns.", {
3578
3861
  project_id: z.string().describe("The project ID"),
3579
3862
  name: z.string().describe("Rule name (e.g. \"Workout Completed\", \"Daily Login Bonus\")"),
@@ -3696,7 +3979,7 @@ function registerTools$13(server, apiClient) {
3696
3979
  }
3697
3980
  //#endregion
3698
3981
  //#region src/tools/events.ts
3699
- function registerTools$12(server, apiClient) {
3982
+ function registerTools$17(server, apiClient) {
3700
3983
  registerTool(server, apiClient, "amba_events_list", "List engagement events for a project (most recent first) with cursor pagination on (occurred_at, id). Defaults to the last 24 hours when `since` is omitted. Pass `next_cursor` from a previous page back as `cursor` to continue.", {
3701
3984
  project_id: z.string().describe("The project ID"),
3702
3985
  since: z.string().optional().describe("ISO-8601 lower bound for occurred_at. Defaults to 24h ago."),
@@ -3736,6 +4019,84 @@ function registerTools$12(server, apiClient) {
3736
4019
  text: JSON.stringify(result, null, 2)
3737
4020
  }] };
3738
4021
  });
4022
+ registerTool(server, apiClient, "amba_events_track", [
4023
+ "Track an engagement event on behalf of a user from the server side — the one call that drives the whole reactive pipeline: streak qualification, currency grant rules, XP awards, achievements, segment membership, and any webhooks subscribed to the event all fan out from this single write.",
4024
+ "Use it to dogfood the event→XP→achievement loop without a device: emit the exact event your app will (e.g. \"mission_completed\" with {xp: 100}) and watch the downstream effects land, then read them back with amba_users_get_xp / amba_achievements_list.",
4025
+ "Pass `event_id` for exactly-once semantics — a re-POST with the same id is a silent no-op and never double-credits a streak or grant. Omit `app_user_id` for a project-scope event (no end-user attribution).",
4026
+ "Send one event, or a `batch` of up to the per-request cap, in a single round-trip. Returns `{data: {tracked, inserted, skipped}}` — `skipped` counts idempotent replays."
4027
+ ].join(" "), {
4028
+ project_id: z.string().describe("The project ID"),
4029
+ event_name: z.string().describe("Event name (e.g. \"mission_completed\", \"lesson_finished\"). Required for a single event.").optional(),
4030
+ app_user_id: z.string().nullable().optional().describe("The app_user UUID this event belongs to. Omit or pass null for a project-scope event with no end-user attribution."),
4031
+ properties: z.record(z.unknown()).optional().describe("Arbitrary event properties (e.g. {xp: 100, level: 3}). Read by grant/XP rules."),
4032
+ occurred_at: z.string().optional().describe("ISO-8601 timestamp the event occurred. Defaults to now."),
4033
+ event_id: z.string().optional().describe("Idempotency key (≤255 chars). A re-POST with the same id is a silent no-op — no double-credit."),
4034
+ batch: z.array(z.object({
4035
+ event_name: z.string().describe("Event name. Required."),
4036
+ app_user_id: z.string().nullable().optional().describe("The app_user UUID. Omit or pass null for a project-scope event."),
4037
+ properties: z.record(z.unknown()).optional().describe("Arbitrary event properties."),
4038
+ occurred_at: z.string().optional().describe("ISO-8601 occurrence timestamp."),
4039
+ event_id: z.string().optional().describe("Idempotency key for this event.")
4040
+ })).optional().describe("Track many events in one call. When provided, the top-level single-event fields are ignored.")
4041
+ }, async ({ project_id, event_name, app_user_id, properties, occurred_at, event_id, batch }, { client }) => {
4042
+ let payload;
4043
+ if (batch !== void 0) payload = { events: batch };
4044
+ else {
4045
+ const single = { event_name };
4046
+ if (app_user_id !== void 0) single.app_user_id = app_user_id;
4047
+ if (properties !== void 0) single.properties = properties;
4048
+ if (occurred_at !== void 0) single.occurred_at = occurred_at;
4049
+ if (event_id !== void 0) single.event_id = event_id;
4050
+ payload = single;
4051
+ }
4052
+ return jsonResult$1(await client.post(`/projects/${project_id}/events`, payload));
4053
+ });
4054
+ }
4055
+ //#endregion
4056
+ //#region src/tools/event-catalog.ts
4057
+ function registerTools$16(server, apiClient) {
4058
+ registerTool(server, apiClient, "amba_events_catalog", "Discover every event you can subscribe a webhook to — both APP events (inside your app: users, gamification, economy, social, content) and CONTROL-plane lifecycle events (about the project itself: provisioning, deploys, domains, billing). Returns each event's type, plane, payload example, and status (`live` = emitted today, `planned` = catalogued, not yet wired). Production pattern: after standing up a project, call this, then subscribe to the lifecycle events you care about (e.g. `domain.registered`, `site.deploy.completed`) with amba_control_webhooks_create and to app events with amba_webhooks_create. Subscriptions match an exact name, a namespace wildcard (`economy.*`), or all (`*`). Pass plane to filter.", {
4059
+ project_id: z.string().describe("The project ID"),
4060
+ plane: z.enum(["app", "control"]).optional().describe("Filter to one plane. Omit for both.")
4061
+ }, async ({ project_id, plane }, { client }) => {
4062
+ const qs = plane ? `?plane=${plane}` : "";
4063
+ const result = await client.get(`/projects/${project_id}/webhooks/catalog${qs}`);
4064
+ return { content: [{
4065
+ type: "text",
4066
+ text: JSON.stringify(result, null, 2)
4067
+ }] };
4068
+ });
4069
+ registerTool(server, apiClient, "amba_control_webhooks_create", "Subscribe a webhook to CONTROL-plane lifecycle events for this project — provisioning, deploys, domains, billing. Amba sends an HMAC-signed POST to target_url whenever a matching event fires. event_name is an exact event (`project.provisioned`), a namespace wildcard (`domain.*`), or all control events (`*`). The signing secret is returned ONCE — copy it into your receiver. target_url must be https:// (http:// only for localhost). Use amba_events_catalog (plane:'control') to see what's available. For APP events use amba_webhooks_create instead.", {
4070
+ project_id: z.string().describe("The project ID"),
4071
+ event_name: z.string().describe("Control event to subscribe to: exact (`project.provisioned`), wildcard (`domain.*`), or `*`."),
4072
+ target_url: z.string().describe("HTTPS endpoint that receives the signed POST. http:// only for localhost.")
4073
+ }, async ({ project_id, event_name, target_url }, { client }) => {
4074
+ const result = await client.post(`/projects/${project_id}/control-webhooks`, {
4075
+ event_name,
4076
+ target_url
4077
+ });
4078
+ return { content: [{
4079
+ type: "text",
4080
+ text: JSON.stringify(result, null, 2)
4081
+ }] };
4082
+ });
4083
+ registerTool(server, apiClient, "amba_control_webhooks_list", "List this project's control-plane webhook subscriptions (most recent first). Secrets are never included.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
4084
+ const result = await client.get(`/projects/${project_id}/control-webhooks`);
4085
+ return { content: [{
4086
+ type: "text",
4087
+ text: JSON.stringify(result, null, 2)
4088
+ }] };
4089
+ });
4090
+ registerTool(server, apiClient, "amba_control_webhooks_delete", "Delete a control-plane webhook subscription by id.", {
4091
+ project_id: z.string().describe("The project ID"),
4092
+ id: z.string().describe("The subscription id")
4093
+ }, async ({ project_id, id }, { client }) => {
4094
+ const result = await client.delete(`/projects/${project_id}/control-webhooks/${id}`);
4095
+ return { content: [{
4096
+ type: "text",
4097
+ text: JSON.stringify(result, null, 2)
4098
+ }] };
4099
+ });
3739
4100
  }
3740
4101
  //#endregion
3741
4102
  //#region src/tools/funnels.ts
@@ -3763,7 +4124,7 @@ const funnelStepSchema = z.object({
3763
4124
  event: z.string().describe("Event name to match (engagement event_name)."),
3764
4125
  filters: z.array(funnelFilterSchema).optional().describe("Optional property predicates, ANDed together, that an event must satisfy.")
3765
4126
  });
3766
- function registerTools$11(server, apiClient) {
4127
+ function registerTools$15(server, apiClient) {
3767
4128
  registerTool(server, apiClient, "amba_funnels_list", "List conversion funnels for a project. A funnel is a saved, ordered sequence of steps (event + optional property filters) plus a conversion window. Returns the definitions only — use amba_funnels_query to compute per-step counts.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
3768
4129
  const result = await client.get(`/projects/${project_id}/funnels`);
3769
4130
  return { content: [{
@@ -3857,56 +4218,6 @@ function registerTools$11(server, apiClient) {
3857
4218
  });
3858
4219
  }
3859
4220
  //#endregion
3860
- //#region src/lib/tool-result.ts
3861
- /**
3862
- * Shared MCP tool result helpers.
3863
- *
3864
- * Every tool emits the same `{ content: [{ type: 'text', text: <json> }] }`
3865
- * envelope. Two helpers centralize that:
3866
- *
3867
- * - [`jsonResult`] — wraps an arbitrary payload.
3868
- * - [`passthroughResult`] — flattens an upstream HTTP response
3869
- * (status + parsed body) into the same envelope. Status is written
3870
- * LAST so a colliding top-level `status` field in the API response
3871
- * cannot shadow the HTTP status — agents look at `parsed.status` to
3872
- * distinguish 2xx from 4xx/5xx.
3873
- *
3874
- * Lives in `src/lib/` (vs. `src/tools/_helpers.ts`) to set the same
3875
- * cross-cutting-helper precedent as `src/lib/with-pat.ts` (task #36).
3876
- * `tools/*` files stay strictly tool registrations.
3877
- */
3878
- /**
3879
- * Wire-shape every MCP tool handler returns.
3880
- *
3881
- * The MCP SDK's `tool()` callback signature is structurally typed and
3882
- * carries an open index signature for `_meta` etc. Declaring our return
3883
- * type as a plain `{ content: [...] }` interface won't satisfy that
3884
- * structural check — so the helpers' return type is left as the actual
3885
- * inferred shape (no explicit interface) and consumers rely on the
3886
- * inference + the SDK's structural compatibility. If we ever want a
3887
- * named alias, write it as a type-alias over the inferred shape rather
3888
- * than a closed interface.
3889
- */
3890
- /** Wrap an arbitrary payload as a JSON-text tool result. */
3891
- function jsonResult$1(payload) {
3892
- return { content: [{
3893
- type: "text",
3894
- text: JSON.stringify(payload, null, 2)
3895
- }] };
3896
- }
3897
- /**
3898
- * Flatten an upstream HTTP response into the agent-facing tool payload.
3899
- * Body fields are spread FIRST so a future top-level `status` key in
3900
- * the API response cannot shadow the HTTP `status` — agents read
3901
- * `parsed.status` to distinguish 2xx from 4xx/5xx.
3902
- */
3903
- function passthroughResult(result) {
3904
- return jsonResult$1({
3905
- ...result.body ?? {},
3906
- status: result.status
3907
- });
3908
- }
3909
- //#endregion
3910
4221
  //#region src/tools/auth.ts
3911
4222
  async function authFetch(apiClient, options) {
3912
4223
  const url = `${apiClient.getApiRoot()}${options.path}`;
@@ -4029,7 +4340,7 @@ function enrichedAuthResult(result, agentInstructions) {
4029
4340
  status: result.status
4030
4341
  });
4031
4342
  }
4032
- function registerTools$10(server, apiClient) {
4343
+ function registerTools$14(server, apiClient) {
4033
4344
  registerPublicTool(server, "amba_developer_signup", [
4034
4345
  "Create a new Amba developer account. Returns a long-lived Personal Access Token (PAT)",
4035
4346
  "plus a real isolated Amba project (provisioning asynchronously), plus ready-to-paste",
@@ -4163,6 +4474,18 @@ async function adminFetch(apiClient, options) {
4163
4474
  body
4164
4475
  };
4165
4476
  }
4477
+ const CATALOG_SHAPE_KEYS = [
4478
+ "is_published",
4479
+ "published_at",
4480
+ "is_public",
4481
+ "published"
4482
+ ];
4483
+ function catalogShapeWarning(row) {
4484
+ const hit = CATALOG_SHAPE_KEYS.filter((k) => k in row);
4485
+ if (hit.length === 0) return null;
4486
+ const ownership = "user_id" in row ? `attributed to user_id "${String(row["user_id"])}"` : "attributed to the seeding user (or rejected if the collection requires user_id)";
4487
+ return [`⚠️ This row has catalog-shaped column(s) [${hit.join(", ")}] but is being inserted into a per-user collection, so it will be ${ownership} — other users will NOT see it via the client SDK.`, "If this is shared/global content (a catalog, articles, a media library), use a Content Library (amba_content_libraries_create) or create the collection with shared:true so its rows are readable by every user. Proceeding anyway."].join(" ");
4488
+ }
4166
4489
  async function clientFetch(apiClient, options) {
4167
4490
  let url = `${apiClient.getApiRoot()}/client${options.path}`;
4168
4491
  if (options.query && Object.keys(options.query).length > 0) url += `?${new URLSearchParams(options.query).toString()}`;
@@ -4200,7 +4523,7 @@ const MISSING_API_KEY_ERROR = jsonResult$1({
4200
4523
  });
4201
4524
  const columnSchema = z.object({
4202
4525
  name: z.string().describe("Column name. Must match /^[a-z][a-z0-9_]*$/."),
4203
- type: z.string().describe("Column type. One of: text, integer, bigint, boolean, jsonb, timestamptz, uuid, numeric, real, double, vector. Vector columns require a \"dimensions\" hint."),
4526
+ type: z.string().describe("Column type. One of: text, integer, bigint, boolean, jsonb, timestamptz, date, uuid, numeric, vector, or an array type (text[], integer[], bigint[], numeric[], boolean[], uuid[]). Array columns support the contains/containedBy/overlaps query operators. Vector columns require a \"dimensions\" hint."),
4204
4527
  nullable: z.boolean().optional().describe("Whether the column accepts NULL. Defaults to true."),
4205
4528
  default: z.unknown().optional().describe("Default SQL literal (e.g. \"NOW()\", 0, \"''\"). Optional."),
4206
4529
  dimensions: z.number().optional().describe("Required for `vector` columns; ignored otherwise.")
@@ -4226,10 +4549,10 @@ const sdkOrderSchema = z.array(z.object({
4226
4549
  })).describe("Ordering. `[{column: \"created_at\", direction: \"desc\"}, ...]`.");
4227
4550
  const adminFindWhereSchema = z.unknown().describe("Server WhereClause shape: column-keyed FieldOps, e.g. {tag: {eq: \"x\"}, count: {gte: 5}} plus optional `and|or|not` combinators.");
4228
4551
  const setSchema = z.record(z.unknown()).describe("Column-value map. Server-managed columns (id, created_at, etc.) are rejected.");
4229
- function registerTools$9(server, apiClient) {
4552
+ function registerTools$13(server, apiClient) {
4230
4553
  registerTool(server, apiClient, "amba_collections_create", [
4231
4554
  "Create a new collection (schema-first Postgres table) in a project.",
4232
- "The DDL is emitted server-side and applied via a Temporal saga.",
4555
+ "The schema change is applied server-side via an async workflow.",
4233
4556
  "Authenticates as the developer/agent — pass `pat` (the Personal Access Token returned by amba_developer_signup) or send it as the inbound Bearer.",
4234
4557
  "Returns the workflow id + version + status. Failure responses include `details.workflow_id` for debugging.",
4235
4558
  "Errors: 400 RESERVED_NAME / INVALID_COLLECTION_SCHEMA / COLLECTION_MIGRATION_FAILED, 401 MISSING_PAT."
@@ -4302,6 +4625,25 @@ function registerTools$9(server, apiClient) {
4302
4625
  bearer: pat
4303
4626
  }));
4304
4627
  }, ["amba_delete_collection"]);
4628
+ registerTool(server, apiClient, "amba_collections_reset_data", [
4629
+ "Reset a collection: empty ALL of its rows in one call WITHOUT dropping the collection — the schema, columns and indexes survive, only the data is cleared. Use this to wipe a fixture/test collection back to empty between runs without re-creating it (use amba_collections_delete to drop the collection itself).",
4630
+ "Hard by default — rows are permanently removed. Pass `hard:false` to soft-delete instead (sets deleted_at so rows stay recoverable).",
4631
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4632
+ "Returns the number of rows cleared. Errors: 404 COLLECTION_NOT_FOUND if the collection does not exist."
4633
+ ].join(" "), {
4634
+ project_id: z.string().describe("The project ID."),
4635
+ name: z.string().describe("Collection name whose rows to clear."),
4636
+ hard: z.boolean().optional().describe("true (default) permanently deletes every row; false soft-deletes (sets deleted_at) so rows remain recoverable.")
4637
+ }, async ({ project_id, name, hard }, { pat }) => {
4638
+ const query = {};
4639
+ if (hard === false) query.hard = "false";
4640
+ return passthroughResult(await adminFetch(apiClient, {
4641
+ method: "DELETE",
4642
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/data`,
4643
+ query,
4644
+ bearer: pat
4645
+ }));
4646
+ });
4305
4647
  registerTool(server, apiClient, "amba_collections_alter", [
4306
4648
  "Alter a collection: exactly one of `add_column`, `add_index`, `change_type`, `rename_column`, `drop_column`, or `relax_user_id` per call (the underlying saga is built around one SQL blob per workflow).",
4307
4649
  "Drop-column is destructive — requires `confirm` to equal the dropped column name.",
@@ -4349,19 +4691,45 @@ function registerTools$9(server, apiClient) {
4349
4691
  registerTool(server, apiClient, "amba_admin_insert_row", [
4350
4692
  "Insert a row directly into a collection from the developer/agent side — BYPASSES auto-RLS.",
4351
4693
  "Server-managed columns (id, created_at, updated_at, deleted_at) are stripped from the body. `user_id` is honored if present (admins can create rows attributed to any app_user).",
4694
+ "For developer-seeded GLOBAL content (question banks, lookup tables, daily content) on a shared:true collection, pass as_system=true to write a row with NO owner (user_id = NULL). Rejected with NOT_SHARED_COLLECTION on a non-shared collection — create it with shared:true (or PATCH relax_user_id:true) first.",
4695
+ "Atomic upsert: pass on_conflict=\"ignore\" (return the existing row, no change) or \"update\" (merge) plus conflict_target (the unique-index columns). Replaces read-then-write races.",
4352
4696
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4353
- "Errors: 400 INVALID_COLUMN, 404 COLLECTION_NOT_FOUND, 500 CREATE_FAILED."
4697
+ "Errors: 400 INVALID_COLUMN / INVALID_CONFLICT_TARGET / NOT_SHARED_COLLECTION, 404 COLLECTION_NOT_FOUND, 500 CREATE_FAILED."
4354
4698
  ].join(" "), {
4355
4699
  project_id: z.string().describe("The project ID."),
4356
4700
  name: z.string().describe("Collection name."),
4357
- row: z.record(z.unknown()).describe("Column-value map for the new row. Server-managed columns are stripped.")
4358
- }, async ({ project_id, name, row }, { pat }) => {
4359
- return passthroughResult(await adminFetch(apiClient, {
4701
+ row: z.record(z.unknown()).describe("Column-value map for the new row. Server-managed columns are stripped."),
4702
+ as_system: z.boolean().optional().describe("Write a developer-owned GLOBAL row (user_id = NULL) on a shared:true collection — for content the whole app reads (question banks, lookup tables) rather than data owned by one end-user. Mutually exclusive with a row.user_id. Rejected on non-shared collections."),
4703
+ on_conflict: z.enum([
4704
+ "error",
4705
+ "ignore",
4706
+ "update"
4707
+ ]).optional().describe("Conflict policy. \"error\" (default) fails on a unique-constraint clash; \"ignore\" returns the existing row (HTTP 200) unchanged; \"update\" merges the provided columns. Requires conflict_target."),
4708
+ conflict_target: z.array(z.string()).optional().describe("Columns that form the unique index to conflict on (e.g. [\"user_id\",\"kind\"]). Required when on_conflict is \"ignore\" or \"update\"."),
4709
+ return_minimal: z.boolean().optional().describe("When true, the response returns only the inserted row id instead of the full row. Use this for rows with large columns (big JSON blobs) to avoid bloating the response — essential for bulk migrations where echoing full rows exhausts the token budget.")
4710
+ }, async ({ project_id, name, row, as_system, on_conflict, conflict_target, return_minimal }, { pat }) => {
4711
+ const query = {};
4712
+ if (on_conflict !== void 0) query.on_conflict = on_conflict;
4713
+ if (conflict_target !== void 0) query.conflict_target = conflict_target.join(",");
4714
+ if (return_minimal) query.return = "minimal";
4715
+ const requestBody = as_system ? {
4716
+ ...row,
4717
+ as_system: true
4718
+ } : row;
4719
+ const result = await adminFetch(apiClient, {
4360
4720
  method: "POST",
4361
4721
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
4362
- body: row,
4722
+ body: requestBody,
4723
+ query: Object.keys(query).length > 0 ? query : void 0,
4363
4724
  bearer: pat
4364
- }));
4725
+ });
4726
+ const warning = catalogShapeWarning(row);
4727
+ const base = passthroughResult(result);
4728
+ if (warning) base.content.unshift({
4729
+ type: "text",
4730
+ text: warning
4731
+ });
4732
+ return base;
4365
4733
  });
4366
4734
  registerTool(server, apiClient, "amba_admin_insert_rows", [
4367
4735
  "Bulk-insert up to 500 rows into a collection in a single atomic statement — BYPASSES auto-RLS.",
@@ -4374,20 +4742,22 @@ function registerTools$9(server, apiClient) {
4374
4742
  project_id: z.string().describe("The project ID."),
4375
4743
  name: z.string().describe("Collection name."),
4376
4744
  rows: z.array(z.record(z.unknown())).min(1).max(500).describe("1–500 column-value maps. Server-managed columns are stripped per row."),
4377
- on_conflict: z.enum(["error", "skip"]).optional().describe("Conflict policy. \"error\" (default) fails the batch; \"skip\" ignores conflicts.")
4378
- }, async ({ project_id, name, rows, on_conflict }, { pat }) => {
4745
+ on_conflict: z.enum(["error", "skip"]).optional().describe("Conflict policy. \"error\" (default) fails the batch; \"skip\" ignores conflicts."),
4746
+ return_minimal: z.boolean().optional().describe("When true, returns only the inserted row ids instead of the full rows. Strongly recommended for large batches or rows with big columns — echoing 500 full rows can exhaust the token budget.")
4747
+ }, async ({ project_id, name, rows, on_conflict, return_minimal }, { pat }) => {
4379
4748
  const body = { rows };
4380
4749
  if (on_conflict !== void 0) body.on_conflict = on_conflict;
4381
4750
  return passthroughResult(await adminFetch(apiClient, {
4382
4751
  method: "POST",
4383
4752
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows:batch`,
4384
4753
  body,
4754
+ query: return_minimal ? { return: "minimal" } : void 0,
4385
4755
  bearer: pat
4386
4756
  }));
4387
4757
  });
4388
4758
  registerTool(server, apiClient, "amba_admin_list_rows", [
4389
4759
  "List rows in a collection from the admin side. BYPASSES auto-RLS — every row in the collection is visible.",
4390
- "Default behavior includes soft-deleted rows (devs debugging \"where did the row go\" need them); pass `include_deleted=false` to hide them.",
4760
+ "Default behavior EXCLUDES soft-deleted rows; pass `include_deleted=true` to include them (e.g. debugging \"where did the row go\").",
4391
4761
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4392
4762
  "Supports the server FindQuery DSL via `where` + `order` + `limit` + `offset` + `cursor` + `select`."
4393
4763
  ].join(" "), {
@@ -4399,7 +4769,7 @@ function registerTools$9(server, apiClient) {
4399
4769
  offset: z.number().optional().describe("Offset for pagination."),
4400
4770
  cursor: z.string().optional().describe("Opaque cursor returned by a prior page."),
4401
4771
  select: z.array(z.string()).optional().describe("Subset of columns to return."),
4402
- include_deleted: z.boolean().optional().describe("Whether to include soft-deleted rows. Admin default is TRUE.")
4772
+ include_deleted: z.boolean().optional().describe("Whether to include soft-deleted rows. Admin default is FALSE.")
4403
4773
  }, async ({ project_id, name, where, order, limit, offset, cursor, select, include_deleted }, { pat }) => {
4404
4774
  const findQuery = {};
4405
4775
  if (where !== void 0) findQuery.where = where;
@@ -4416,21 +4786,157 @@ function registerTools$9(server, apiClient) {
4416
4786
  bearer: pat
4417
4787
  }));
4418
4788
  });
4789
+ registerTool(server, apiClient, "amba_admin_aggregate_rows", [
4790
+ "Run a group-by aggregation over a collection from the admin side — BYPASSES auto-RLS.",
4791
+ "Supports count / sum / avg / min / max, optionally grouped by one or more columns. count without a column = COUNT(*).",
4792
+ "Default EXCLUDES soft-deleted rows; pass include_deleted=true to include them.",
4793
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4794
+ "Returns one row per group (or a single row when group_by is omitted), with each aggregation under its alias."
4795
+ ].join(" "), {
4796
+ project_id: z.string().describe("The project ID."),
4797
+ name: z.string().describe("Collection name."),
4798
+ select: z.array(z.object({
4799
+ fn: z.enum([
4800
+ "count",
4801
+ "sum",
4802
+ "avg",
4803
+ "min",
4804
+ "max"
4805
+ ]),
4806
+ column: z.string().optional().describe("Column to aggregate. Required for sum/avg/min/max; omit for COUNT(*)."),
4807
+ as: z.string().optional().describe("Output alias (defaults to fn or fn_column).")
4808
+ })).min(1).describe("One or more aggregations to compute."),
4809
+ group_by: z.union([z.string(), z.array(z.string())]).optional().describe("Column(s) to group by. Omit for a whole-collection aggregate."),
4810
+ where: adminFindWhereSchema.optional(),
4811
+ include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default FALSE.")
4812
+ }, async ({ project_id, name, select, group_by, where, include_deleted }, { pat }) => {
4813
+ const body = { select };
4814
+ if (group_by !== void 0) body.group_by = group_by;
4815
+ if (where !== void 0) body.where = where;
4816
+ if (include_deleted !== void 0) body.includeDeleted = include_deleted;
4817
+ return passthroughResult(await adminFetch(apiClient, {
4818
+ method: "POST",
4819
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows/aggregate`,
4820
+ body,
4821
+ bearer: pat
4822
+ }));
4823
+ });
4824
+ registerTool(server, apiClient, "amba_admin_update_row", [
4825
+ "Update a single row by id from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce, so it can edit ANY row regardless of which app_user owns it.",
4826
+ "Pass `set` (a column→value map of the fields to write). Server-managed columns (id, created_at, updated_at, deleted_at) are rejected with 400 PROTECTED_COLUMN.",
4827
+ "Compare-and-set (optimistic concurrency): pass `expected` (a column→expected-value map) and the update applies ONLY if the row currently still matches those values — otherwise 409 PRECONDITION_FAILED and nothing is written. Null-safe, so `expected: {status: null}` matches a NULL column. Use this for safe read-modify-write (read the row, then update guarded by the values you read) without a transaction.",
4828
+ "For updating many rows by a filter instead of one id, use amba_admin_bulk_update.",
4829
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4830
+ "Errors: 400 PROTECTED_COLUMN / INVALID_COLUMN / INVALID_BODY, 404 NOT_FOUND (no such row id), 409 PRECONDITION_FAILED (expected mismatch), 404 COLLECTION_NOT_FOUND."
4831
+ ].join(" "), {
4832
+ project_id: z.string().describe("The project ID."),
4833
+ name: z.string().describe("Collection name."),
4834
+ id: z.string().describe("Row UUID to update."),
4835
+ set: setSchema.describe("Column-value map to write. Server-managed columns are rejected."),
4836
+ expected: z.record(z.unknown()).optional().describe("Compare-and-set precondition: column→expected-value map. The update applies only if the row currently matches every entry; otherwise 409 PRECONDITION_FAILED and no write occurs. Null-safe (an entry of null matches a NULL column).")
4837
+ }, async ({ project_id, name, id, set, expected }, { pat }) => {
4838
+ return passthroughResult(await adminFetch(apiClient, {
4839
+ method: "PATCH",
4840
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows/${encodeURIComponent(id)}`,
4841
+ body: expected !== void 0 ? {
4842
+ set,
4843
+ expected
4844
+ } : { set },
4845
+ bearer: pat
4846
+ }));
4847
+ });
4848
+ registerTool(server, apiClient, "amba_admin_delete_row", [
4849
+ "Delete a single row by id from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce, so it can delete ANY row regardless of which app_user owns it.",
4850
+ "Soft-delete by default (sets deleted_at = NOW() so the row stays recoverable and still appears in admin_list_rows with include_deleted=true). Pass hard=true for a permanent, irreversible DELETE that removes the row entirely.",
4851
+ "Deleting an already-soft-deleted row returns `already_deleted: true` (200), not an error.",
4852
+ "For deleting many rows by a filter instead of one id, use amba_admin_bulk_delete.",
4853
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4854
+ "Errors: 404 NOT_FOUND (no such row id), 404 COLLECTION_NOT_FOUND, 400 INVALID_UUID."
4855
+ ].join(" "), {
4856
+ project_id: z.string().describe("The project ID."),
4857
+ name: z.string().describe("Collection name."),
4858
+ id: z.string().describe("Row UUID to delete."),
4859
+ hard: z.boolean().optional().describe("false (default) soft-deletes (sets deleted_at so the row stays recoverable); true permanently removes the row (irreversible).")
4860
+ }, async ({ project_id, name, id, hard }, { pat }) => {
4861
+ return passthroughResult(await adminFetch(apiClient, {
4862
+ method: "DELETE",
4863
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows/${encodeURIComponent(id)}`,
4864
+ query: hard === true ? { hard: "true" } : void 0,
4865
+ bearer: pat
4866
+ }));
4867
+ });
4868
+ registerTool(server, apiClient, "amba_admin_bulk_update", [
4869
+ "Update EVERY row matching a `where` filter in one statement, from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce (updates rows across all app_users).",
4870
+ "`where` is REQUIRED — a bulk update with no filter (which would touch the entire collection) is rejected with 400 WHERE_REQUIRED. To update a single row by id, use amba_admin_update_row instead.",
4871
+ "`set` is the column→value map applied to every matched row. Server-managed columns are rejected with 400 PROTECTED_COLUMN.",
4872
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4873
+ "Returns `{data: {updated: <n>, ids: [...]}}`. Errors: 400 WHERE_REQUIRED / PROTECTED_COLUMN / INVALID_COLUMN, 404 COLLECTION_NOT_FOUND."
4874
+ ].join(" "), {
4875
+ project_id: z.string().describe("The project ID."),
4876
+ name: z.string().describe("Collection name."),
4877
+ set: setSchema.describe("Column-value map applied to every matched row. Required."),
4878
+ where: adminFindWhereSchema.describe("Server WhereClause selecting which rows to update. REQUIRED — there is no \"update all\" shortcut; pass a filter that matches the rows you intend to change.")
4879
+ }, async ({ project_id, name, set, where }, { pat }) => {
4880
+ return passthroughResult(await adminFetch(apiClient, {
4881
+ method: "PATCH",
4882
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
4883
+ body: {
4884
+ set,
4885
+ where
4886
+ },
4887
+ bearer: pat
4888
+ }));
4889
+ });
4890
+ registerTool(server, apiClient, "amba_admin_bulk_delete", [
4891
+ "Delete EVERY row matching a `where` filter in one statement, from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce (deletes rows across all app_users).",
4892
+ "`where` is REQUIRED — a bulk delete with no filter is rejected. To delete a single row by id, use amba_admin_delete_row.",
4893
+ "Foot-gun guard: `confirm` MUST equal the exact number of rows the `where` filter currently matches. Preview that count first with amba_admin_list_rows (or the count endpoint), then pass it as `confirm`. If the live count differs (e.g. a concurrent write changed it), the delete aborts with 400 CONFIRMATION_MISMATCH and nothing is removed.",
4894
+ "Soft-delete by default (sets deleted_at). Pass hard=true for a permanent, irreversible DELETE of every matched row.",
4895
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4896
+ "Returns `{data: {deleted: <n>, ids: [...], mode}}`. Errors: 400 WHERE_REQUIRED / CONFIRMATION_REQUIRED / CONFIRMATION_MISMATCH, 404 COLLECTION_NOT_FOUND."
4897
+ ].join(" "), {
4898
+ project_id: z.string().describe("The project ID."),
4899
+ name: z.string().describe("Collection name."),
4900
+ where: adminFindWhereSchema.describe("Server WhereClause selecting which rows to delete. REQUIRED."),
4901
+ confirm: z.number().int().nonnegative().describe("The exact number of rows the `where` filter currently matches (preview via amba_admin_list_rows). The delete proceeds only if the live count equals this; otherwise 400 CONFIRMATION_MISMATCH and nothing is deleted."),
4902
+ hard: z.boolean().optional().describe("false (default) soft-deletes (sets deleted_at, recoverable); true permanently removes every matched row (irreversible).")
4903
+ }, async ({ project_id, name, where, confirm, hard }, { pat }) => {
4904
+ const query = { confirm: String(confirm) };
4905
+ if (hard === true) query.hard = "true";
4906
+ return passthroughResult(await adminFetch(apiClient, {
4907
+ method: "DELETE",
4908
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
4909
+ body: { where },
4910
+ query,
4911
+ bearer: pat
4912
+ }));
4913
+ });
4419
4914
  server.tool("amba_client_insert_row", [
4420
4915
  "Insert a row into a collection as the signed-in app_user. Auto-RLS injects `user_id = <session app_user>` server-side; the body cannot override it.",
4916
+ "Atomic upsert: pass on_conflict=\"ignore\" (return existing row, HTTP 200) or \"update\" (merge) plus conflict_target (unique-index columns, may include user_id). Removes read-then-write races (duplicate profiles, split rooms, etc.).",
4421
4917
  "Authenticates as the end-user — pass `api_key` (project client X-Api-Key) plus `session_token` (the app_user's Bearer).",
4422
- "Errors: 400 INVALID_COLUMN, 401 if api_key/session missing, 404 COLLECTION_NOT_FOUND."
4918
+ "Errors: 400 INVALID_COLUMN / INVALID_CONFLICT_TARGET, 401 if api_key/session missing, 404 COLLECTION_NOT_FOUND."
4423
4919
  ].join(" "), {
4424
4920
  name: z.string().describe("Collection name."),
4425
4921
  row: z.record(z.unknown()).describe("Column-value map. Server-managed columns and user_id are stripped/overridden."),
4922
+ on_conflict: z.enum([
4923
+ "error",
4924
+ "ignore",
4925
+ "update"
4926
+ ]).optional().describe("Conflict policy. \"error\" (default), \"ignore\" (return existing unchanged), or \"update\" (merge). Requires conflict_target."),
4927
+ conflict_target: z.array(z.string()).optional().describe("Unique-index columns to conflict on (e.g. [\"user_id\",\"kind\"]). Required for ignore/update."),
4426
4928
  api_key: z.string().describe("Project client X-Api-Key. End-user tools authenticate via this + an optional session token, NOT via a developer PAT."),
4427
4929
  session_token: z.string().optional().describe("Optional app_user session Bearer token. Required by the route's clientSessionAuth.")
4428
- }, async ({ name, row, api_key, session_token }) => {
4930
+ }, async ({ name, row, on_conflict, conflict_target, api_key, session_token }) => {
4429
4931
  if (!api_key) return MISSING_API_KEY_ERROR;
4932
+ const query = {};
4933
+ if (on_conflict !== void 0) query.on_conflict = on_conflict;
4934
+ if (conflict_target !== void 0) query.conflict_target = conflict_target.join(",");
4430
4935
  return passthroughResult(await clientFetch(apiClient, {
4431
4936
  method: "POST",
4432
4937
  path: `/collections/${encodeURIComponent(name)}`,
4433
4938
  body: row,
4939
+ ...Object.keys(query).length > 0 && { query },
4434
4940
  apiKey: api_key,
4435
4941
  ...session_token !== void 0 && { sessionToken: session_token }
4436
4942
  }));
@@ -4488,6 +4994,7 @@ function registerTools$9(server, apiClient) {
4488
4994
  });
4489
4995
  server.tool("amba_client_update_row", [
4490
4996
  "Update a single row by id. Auto-RLS — only succeeds if the row belongs to the signed-in app_user.",
4997
+ "Compare-and-set: pass `expected` (a column→value map) on a single-row update; the write applies only if the row still matches, else 409 PRECONDITION_FAILED. Null-safe. Optimistic concurrency without a transaction.",
4491
4998
  "For bulk updates with a where-clause, omit `id` and pass `where` + `set`.",
4492
4999
  "Server-managed columns and user_id are rejected with 400 INVALID_COLUMN.",
4493
5000
  "Authenticates as the end-user — pass `api_key` + `session_token`."
@@ -4496,15 +5003,19 @@ function registerTools$9(server, apiClient) {
4496
5003
  id: z.string().optional().describe("Row UUID for single-row update. Omit to bulk update via `where`."),
4497
5004
  where: adminFindWhereSchema.optional().describe("Required for bulk update (when `id` is omitted). `where: {}` matches all rows the user owns."),
4498
5005
  set: setSchema.describe("Column-value map to write. Required."),
5006
+ expected: z.record(z.unknown()).optional().describe("Compare-and-set precondition (single-row only): column→expected-value map. The update applies only if the row currently matches; otherwise 409 PRECONDITION_FAILED."),
4499
5007
  limit: z.number().optional().describe("Bulk-update cap (default 1000, max 10000). Ignored when `id` is set."),
4500
5008
  api_key: z.string().describe("Project client X-Api-Key."),
4501
5009
  session_token: z.string().optional().describe("App_user session Bearer. Required by the route.")
4502
- }, async ({ name, id, where, set, limit, api_key, session_token }) => {
5010
+ }, async ({ name, id, where, set, expected, limit, api_key, session_token }) => {
4503
5011
  if (!api_key) return MISSING_API_KEY_ERROR;
4504
5012
  if (id !== void 0) return passthroughResult(await clientFetch(apiClient, {
4505
5013
  method: "PATCH",
4506
5014
  path: `/collections/${encodeURIComponent(name)}/${encodeURIComponent(id)}`,
4507
- body: { set },
5015
+ body: expected !== void 0 ? {
5016
+ set,
5017
+ expected
5018
+ } : { set },
4508
5019
  apiKey: api_key,
4509
5020
  ...session_token !== void 0 && { sessionToken: session_token }
4510
5021
  }));
@@ -4575,24 +5086,32 @@ function registerTools$9(server, apiClient) {
4575
5086
  });
4576
5087
  server.tool("amba_client_find_rows", [
4577
5088
  "Query rows with the @layers/amba SDK-shaped filter body. POSTs to `/v1/client/collections/:name/find` — preferred over `amba_client_list_rows` for anything beyond a flat scan because GET-with-body is fragile across HTTP intermediaries.",
4578
- "Filter shape: leaf `{column, op, value}` (op ∈ eq|ne|gt|gte|lt|lte|in|not_in|like|ilike|is_null|is_not_null) or combinator `{and: [...]}` / `{or: [...]}` / `{not: {...}}`.",
4579
- "Auto-RLS — every match is AND'd with `user_id = <session app_user> AND deleted_at IS NULL` server-side.",
5089
+ "Filter shape: leaf `{column, op, value}` (op ∈ eq|ne|gt|gte|lt|lte|in|not_in|like|ilike|is_null|is_not_null|contains|contained_by|overlaps) or combinator `{and: [...]}` / `{or: [...]}` / `{not: {...}}`.",
5090
+ "Keyword/fuzzy search: pass `search: { q, columns, fuzzy?, threshold? }` for classical catalog search across text columns (title/author/tags). Substring (ILIKE) by default; `fuzzy: true` is typo-tolerant and ranks by relevance.",
5091
+ "Auto-RLS — every match is AND'd with `user_id = <session app_user> AND deleted_at IS NULL` server-side (shared/unowned rows are also returned).",
4580
5092
  "Authenticates as the end-user — pass `api_key` + `session_token`."
4581
5093
  ].join(" "), {
4582
5094
  name: z.string().describe("Collection name."),
4583
5095
  filter: sdkFilterSchema.optional(),
4584
5096
  order: sdkOrderSchema.optional(),
5097
+ search: z.object({
5098
+ q: z.string().describe("Search text."),
5099
+ columns: z.array(z.string()).min(1).describe("Text columns to search."),
5100
+ fuzzy: z.boolean().optional().describe("Typo-tolerant trigram match + relevance ranking. Default false (substring)."),
5101
+ threshold: z.number().optional().describe("Fuzzy only: similarity threshold 0..1 (default 0.3).")
5102
+ }).optional().describe("Keyword / fuzzy text search across columns."),
4585
5103
  limit: z.number().optional().describe("Max rows."),
4586
5104
  cursor: z.string().optional().describe("Opaque cursor returned by a prior page."),
4587
5105
  select: z.array(z.string()).optional().describe("Subset of columns to return."),
4588
5106
  include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default FALSE."),
4589
5107
  api_key: z.string().describe("Project client X-Api-Key."),
4590
5108
  session_token: z.string().optional().describe("App_user session Bearer. Required by the route.")
4591
- }, async ({ name, filter, order, limit, cursor, select, include_deleted, api_key, session_token }) => {
5109
+ }, async ({ name, filter, order, search, limit, cursor, select, include_deleted, api_key, session_token }) => {
4592
5110
  if (!api_key) return MISSING_API_KEY_ERROR;
4593
5111
  const body = {};
4594
5112
  if (filter !== void 0) body.filter = filter;
4595
5113
  if (order !== void 0) body.order = order;
5114
+ if (search !== void 0) body.search = search;
4596
5115
  if (limit !== void 0) body.limit = limit;
4597
5116
  if (cursor !== void 0) body.cursor = cursor;
4598
5117
  if (select !== void 0) body.select = select;
@@ -4649,6 +5168,47 @@ function registerTools$9(server, apiClient) {
4649
5168
  ...session_token !== void 0 && { sessionToken: session_token }
4650
5169
  }));
4651
5170
  });
5171
+ server.tool("amba_client_aggregate_rows", [
5172
+ "Run a group-by aggregation over a collection as the signed-in app_user — server-side, no rows shipped to the client.",
5173
+ "Supports count / sum / avg / min / max, optionally grouped by one or more columns. count without a column = COUNT(*).",
5174
+ "Auto-RLS — the aggregate covers only rows the app_user owns (plus shared/unowned rows) AND'd with the live deleted_at filter; pass include_deleted=true to include soft-deleted rows.",
5175
+ "Roll a leaderboard, a spend total, a per-category count, or a daily streak summary in one round-trip instead of paging every row. Pairs naturally with amba_client_find_rows for the drill-down.",
5176
+ "Authenticates as the end-user — pass `api_key` + `session_token`.",
5177
+ "Returns `{data: [...]}` — one row per group (or a single row when group_by is omitted), each aggregation under its alias."
5178
+ ].join(" "), {
5179
+ name: z.string().describe("Collection name."),
5180
+ select: z.array(z.object({
5181
+ fn: z.enum([
5182
+ "count",
5183
+ "sum",
5184
+ "avg",
5185
+ "min",
5186
+ "max"
5187
+ ]),
5188
+ column: z.string().optional().describe("Column to aggregate. Required for sum/avg/min/max; omit for COUNT(*)."),
5189
+ as: z.string().optional().describe("Output alias (defaults to fn or fn_column).")
5190
+ })).min(1).describe("One or more aggregations to compute."),
5191
+ group_by: z.union([z.string(), z.array(z.string())]).optional().describe("Column(s) to group by. Omit for a whole-collection aggregate."),
5192
+ filter: sdkFilterSchema.optional(),
5193
+ where: adminFindWhereSchema.optional(),
5194
+ include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default FALSE."),
5195
+ api_key: z.string().describe("Project client X-Api-Key."),
5196
+ session_token: z.string().optional().describe("App_user session Bearer. Required by the route.")
5197
+ }, async ({ name, select, group_by, filter, where, include_deleted, api_key, session_token }) => {
5198
+ if (!api_key) return MISSING_API_KEY_ERROR;
5199
+ const body = { select };
5200
+ if (group_by !== void 0) body.group_by = group_by;
5201
+ if (filter !== void 0) body.filter = filter;
5202
+ if (where !== void 0) body.where = where;
5203
+ if (include_deleted !== void 0) body.include_deleted = include_deleted;
5204
+ return passthroughResult(await clientFetch(apiClient, {
5205
+ method: "POST",
5206
+ path: `/collections/${encodeURIComponent(name)}/aggregate`,
5207
+ body,
5208
+ apiKey: api_key,
5209
+ ...session_token !== void 0 && { sessionToken: session_token }
5210
+ }));
5211
+ });
4652
5212
  }
4653
5213
  //#endregion
4654
5214
  //#region src/tools/_pat.ts
@@ -4764,24 +5324,27 @@ function jsonResult(payload) {
4764
5324
  * of a generic upstream 400.
4765
5325
  */
4766
5326
  const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
4767
- function registerTools$8(server, apiClient) {
5327
+ function registerTools$12(server, apiClient) {
4768
5328
  registerTool(server, apiClient, "amba_functions_deploy", [
4769
5329
  "Deploy a function to an Amba project. The supplied `code` is a fully bundled JavaScript module (one entry file) — the deploy endpoint uploads it server-side and records a new active deployment row, superseding any prior active version for the same `name`.",
4770
5330
  "Function name must match /^[a-z][a-z0-9_-]*$/ and be ≤58 chars. Bundle size cap: 10 MiB (enforced client-side too — oversize uploads are rejected before the round-trip).",
4771
5331
  "Optional `rate_limit` declares a per-function rate-limit config (validated server-side). Pass null/omit for no rate limit.",
5332
+ "Optional `public` (default false) deploys the function WITHOUT the X-Api-Key gate, so third-party webhook senders (RevenueCat, Stripe, GitHub, Slack) that cannot attach a custom header can reach it. SECURITY: when public, YOU must verify the webhook signature (HMAC) inside the function body — the runtime no longer requires a caller credential. `public: true` only skips the key requirement; rate-limiting and the per-request signed identity context still apply. Leave it false for functions only your own app calls.",
4772
5333
  "Returns the function deployment row + the public URL (`fn_url`: `https://{project_slug}.fn.amba.host/{name}`)."
4773
5334
  ].join(" "), {
4774
5335
  project_id: z.string().describe("The Amba project ID."),
4775
5336
  name: z.string().describe("Function name. Lowercase, /^[a-z][a-z0-9_-]*$/, ≤58 chars."),
4776
5337
  code: z.string().describe("Bundled JavaScript module source (one entry file)."),
4777
- rate_limit: z.unknown().optional().describe("Optional rate-limit config object. Shape: see @layers/amba-shared:RateLimitConfig.")
4778
- }, async ({ project_id, name, code, rate_limit }, { pat }) => {
5338
+ rate_limit: z.unknown().optional().describe("Optional rate-limit config object. Shape: see @layers/amba-shared:RateLimitConfig."),
5339
+ public: z.boolean().optional().describe("Deploy the function as PUBLIC (no X-Api-Key required) so external webhook senders can reach it. Default false. When true, you MUST verify the webhook HMAC signature inside the function body — public skips the credential gate only; rate-limiting and signed identity context still apply.")
5340
+ }, async ({ project_id, name, code, rate_limit, public: isPublic }, { pat }) => {
4779
5341
  const byteLen = Buffer.byteLength(code, "utf8");
4780
5342
  if (byteLen > MAX_BUNDLE_BYTES) throw new Error(`Bundle size ${byteLen} bytes exceeds ${MAX_BUNDLE_BYTES}-byte cap (10 MiB).`);
4781
5343
  const form = new FormData();
4782
5344
  form.append("script", new Blob([code], { type: "application/javascript" }), "bundle.js");
4783
5345
  const metadata = { name };
4784
5346
  if (rate_limit !== void 0 && rate_limit !== null) metadata.rate_limit = rate_limit;
5347
+ if (isPublic === true) metadata.public = true;
4785
5348
  form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
4786
5349
  return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/functions/deploy`, { formData: form }));
4787
5350
  }, ["amba_deploy_function"]);
@@ -4889,7 +5452,7 @@ function registerTools$8(server, apiClient) {
4889
5452
  }
4890
5453
  //#endregion
4891
5454
  //#region src/tools/sites.ts
4892
- function registerTools$7(server, apiClient) {
5455
+ function registerTools$11(server, apiClient) {
4893
5456
  registerTool(server, apiClient, "amba_sites_deploy", [
4894
5457
  "Deploy a static site to an Amba project. Creates the site if it does not yet exist, then uploads the supplied files as a new deployment.",
4895
5458
  "The deployment is immutable — its files live forever under the site's storage prefix — and the site flips to serve the new deployment atomically. Older deployments are kept for rollback via `amba_sites_list`.",
@@ -4981,7 +5544,7 @@ function registerTools$7(server, apiClient) {
4981
5544
  }
4982
5545
  //#endregion
4983
5546
  //#region src/tools/domains.ts
4984
- function registerTools$6(server, apiClient) {
5547
+ function registerTools$10(server, apiClient) {
4985
5548
  registerTool(server, apiClient, "amba_domains_search", ["Search for available domains to buy through Amba. Pass a keyword or a full domain; returns candidate domains with availability and first-year price (USD).", "Free — searching never costs anything. Use this to help a user pick a name, then `amba_domains_check` to price specific picks and `amba_domains_purchase` to buy."].join(" "), {
4986
5549
  project_id: z.string().describe("The Amba project ID."),
4987
5550
  query: z.string().describe("A keyword (e.g. \"getunbury\") or a full domain (e.g. \"unbury.com\")."),
@@ -5036,8 +5599,41 @@ function registerTools$6(server, apiClient) {
5036
5599
  });
5037
5600
  }
5038
5601
  //#endregion
5602
+ //#region src/tools/operations.ts
5603
+ function registerTools$9(server, apiClient) {
5604
+ registerTool(server, apiClient, "amba_operations_get", [
5605
+ "Poll an async operation by its operation_id to see whether it has finished.",
5606
+ "Returns the operation `status` (pending | running | succeeded | failed); on `failed`, `failed_reason` says why, and on `succeeded`, `result` holds the outcome.",
5607
+ "Use this after a tool returns an `operation_id` (e.g. amba_domains_purchase): re-call until status is succeeded or failed instead of re-running the action."
5608
+ ].join(" "), {
5609
+ project_id: z.string().describe("The Amba project ID."),
5610
+ operation_id: z.string().describe("The operation handle id returned by the tool that started the work.")
5611
+ }, async ({ project_id, operation_id }, { pat }) => {
5612
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/operations/${encodeURIComponent(operation_id)}`));
5613
+ });
5614
+ registerTool(server, apiClient, "amba_operations_list", ["List async operations for a project, newest first. Optionally filter by `kind` (e.g. \"domain_purchase\") and/or `status` (pending | running | succeeded | failed).", "Read-only — useful to find an in-flight operation or review recent ones."].join(" "), {
5615
+ project_id: z.string().describe("The Amba project ID."),
5616
+ kind: z.string().optional().describe("Filter to one operation kind, e.g. \"domain_purchase\"."),
5617
+ status: z.enum([
5618
+ "pending",
5619
+ "running",
5620
+ "succeeded",
5621
+ "failed"
5622
+ ]).optional().describe("Filter to one lifecycle status."),
5623
+ limit: z.number().int().min(1).max(200).optional().describe("Page size (1-200, default 50)."),
5624
+ offset: z.number().int().min(0).optional().describe("Skip rows (default 0).")
5625
+ }, async ({ project_id, kind, status, limit, offset }, { pat }) => {
5626
+ const query = {};
5627
+ if (kind !== void 0) query.kind = kind;
5628
+ if (status !== void 0) query.status = status;
5629
+ if (limit !== void 0) query.limit = String(limit);
5630
+ if (offset !== void 0) query.offset = String(offset);
5631
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/operations`, { query }));
5632
+ });
5633
+ }
5634
+ //#endregion
5039
5635
  //#region src/tools/secrets.ts
5040
- function registerTools$5(server, apiClient) {
5636
+ function registerTools$8(server, apiClient) {
5041
5637
  registerTool(server, apiClient, "amba_secrets_set", [
5042
5638
  "Set or rotate a secret. Omit `function` for a PROJECT-WIDE secret (one value visible to every function in the project, including ones deployed later) — set it once instead of repeating per function. Pass `function` to scope the secret to just that function.",
5043
5639
  "Writes a new version to the backing secret store and enqueues the sync. Subsequent function invocations see the new value once sync_status flips to \"synced\" (typically <30s).",
@@ -5107,22 +5703,26 @@ function registerTools$5(server, apiClient) {
5107
5703
  }
5108
5704
  //#endregion
5109
5705
  //#region src/tools/ai-prompts-admin.ts
5110
- const PROVIDER_ENUM = z.enum(["anthropic", "openai"]);
5706
+ const PROVIDER_ENUM$1 = z.enum([
5707
+ "anthropic",
5708
+ "openai",
5709
+ "mistral"
5710
+ ]);
5111
5711
  const RATE_LIMIT_SHAPE = z.object({
5112
5712
  window: z.string().optional().describe("Window length (e.g. \"60s\", \"1m\", \"1h\"). Defaults to \"60s\"."),
5113
5713
  max: z.number().int().min(1).optional().describe("Max invocations per window. Defaults to 20."),
5114
5714
  key: z.string().optional().describe("Bucketing key. Typically \"user_id\" or \"session_id\". Defaults to \"user_id\".")
5115
5715
  }).strict();
5116
- function registerTools$4(server, apiClient) {
5716
+ function registerTools$7(server, apiClient) {
5117
5717
  registerTool(server, apiClient, "amba_ai_prompts_create", [
5118
5718
  "Register a new AI prompt template on a project. Returns the persisted prompt row (name, version, provider, model, client_invokable).",
5119
- "Name must match /^[a-z][a-z0-9_-]{0,127}$/. Provider must be \"anthropic\" or \"openai\" and must already be registered (POST /ai/providers — currently CLI / console). Model is the provider-native model id (e.g. \"claude-sonnet-4-5-20250929\", \"gpt-4o-2024-08-06\").",
5719
+ "Name must match /^[a-z][a-z0-9_-]{0,127}$/. Provider must be \"anthropic\", \"openai\", or \"mistral\" and must already be registered via `amba_ai_providers_set`. Model is the provider-native model id (e.g. \"claude-sonnet-4-5-20250929\", \"gpt-4o-2024-08-06\").",
5120
5720
  "Set `client_invokable=true` to allow SDK callers (e.g. amba-web, amba-react) to invoke the prompt via `Amba.ai.run()`. Defaults false — server-side / admin invocation only.",
5121
5721
  "Re-issuing this tool with an existing `name` will bump the prompt to a new version; prefer `amba_ai_prompts_update` for that intent."
5122
5722
  ].join(" "), {
5123
5723
  project_id: z.string().describe("The Amba project ID."),
5124
5724
  name: z.string().describe("Prompt name. /^[a-z][a-z0-9_-]{0,127}$/."),
5125
- provider: PROVIDER_ENUM.describe("AI provider. Must already be registered."),
5725
+ provider: PROVIDER_ENUM$1.describe("AI provider. Must already be registered via `amba_ai_providers_set`."),
5126
5726
  model: z.string().describe("Provider-native model id."),
5127
5727
  system_prompt: z.string().nullable().optional().describe("System prompt sent on every invocation. Omit or null for no system prompt."),
5128
5728
  max_tokens: z.number().int().min(1).max(2e5).optional().describe("Max output tokens per invocation. Defaults to 4096."),
@@ -5152,7 +5752,7 @@ function registerTools$4(server, apiClient) {
5152
5752
  registerTool(server, apiClient, "amba_ai_prompts_update", ["Update an existing AI prompt template. The new version replaces the old (incrementing `version`). Every field you supply overwrites the prior value; the API replaces ALL fields on every upsert, so always pass the full intended state.", "Returns the persisted row (with the bumped version)."].join(" "), {
5153
5753
  project_id: z.string().describe("The Amba project ID."),
5154
5754
  name: z.string().describe("Prompt name to update."),
5155
- provider: PROVIDER_ENUM.describe("AI provider."),
5755
+ provider: PROVIDER_ENUM$1.describe("AI provider."),
5156
5756
  model: z.string().describe("Provider-native model id."),
5157
5757
  system_prompt: z.string().nullable().optional().describe("System prompt; null/omit to clear."),
5158
5758
  max_tokens: z.number().int().min(1).max(2e5).optional().describe("Max output tokens."),
@@ -5232,6 +5832,179 @@ function registerTools$4(server, apiClient) {
5232
5832
  }, ["amba_invoke_ai_prompt"]);
5233
5833
  }
5234
5834
  //#endregion
5835
+ //#region src/tools/ai-providers-admin.ts
5836
+ const PROVIDER_ENUM = z.enum([
5837
+ "anthropic",
5838
+ "openai",
5839
+ "mistral",
5840
+ "gemini"
5841
+ ]);
5842
+ function registerTools$6(server, apiClient) {
5843
+ registerTool(server, apiClient, "amba_ai_providers_set", [
5844
+ "Register or rotate the upstream AI provider API key for a project. This is the key the AI gateway uses to call the model on your behalf when you invoke a prompt or `Amba.ai.*`.",
5845
+ "Call this BEFORE `amba_ai_prompts_create` — a prompt references a registered provider, and invocations fail with `provider_not_configured` (424) until a key is set.",
5846
+ "This is NOT `amba_secrets_set`: that writes function-scoped Worker secrets, which the AI gateway never reads. The provider key lives in a separate gateway-owned namespace and is set only here.",
5847
+ "The key is stored server-side; it is never returned. The response includes a short masked preview (`api_key_preview`) so you can confirm which key is active."
5848
+ ].join(" "), {
5849
+ project_id: z.string().describe("The Amba project ID."),
5850
+ provider: PROVIDER_ENUM.describe("Provider the key belongs to: \"anthropic\", \"openai\", \"mistral\", or \"gemini\"."),
5851
+ api_key: z.string().min(10).describe("The upstream provider API key (e.g. an Anthropic or OpenAI account key).")
5852
+ }, async ({ project_id, provider, api_key }, { pat }) => {
5853
+ return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/ai/providers`, { body: {
5854
+ name: provider,
5855
+ api_key
5856
+ } }));
5857
+ });
5858
+ registerTool(server, apiClient, "amba_ai_providers_list", "List the AI providers registered on a project. Returns each provider with a `configured` boolean (true once a key is set) and timestamps. Keys themselves are never returned.", { project_id: z.string().describe("The Amba project ID.") }, async ({ project_id }, { pat }) => {
5859
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/ai/providers`));
5860
+ });
5861
+ registerTool(server, apiClient, "amba_ai_providers_delete", "Revoke a registered AI provider key. Rejected with 409 PROVIDER_HAS_PROMPTS if any prompt still references the provider — delete those prompts first.", {
5862
+ project_id: z.string().describe("The Amba project ID."),
5863
+ provider: PROVIDER_ENUM.describe("Provider to revoke.")
5864
+ }, async ({ project_id, provider }, { pat }) => {
5865
+ return jsonResult(await callWithPat(apiClient, pat, "DELETE", `/projects/${encodeURIComponent(project_id)}/ai/providers/${encodeURIComponent(provider)}`) ?? {
5866
+ name: provider,
5867
+ deleted: true
5868
+ });
5869
+ });
5870
+ }
5871
+ //#endregion
5872
+ //#region src/tools/experiments.ts
5873
+ const variantSchema = z.object({
5874
+ key: z.string().describe("Variant key (e.g. \"control\", \"treatment\"). Unique within the experiment."),
5875
+ weight: z.number().int().min(1).max(1e6).describe("Relative allocation weight (positive integer). Weights need not sum to 100.")
5876
+ });
5877
+ const STATUS_ENUM = z.enum([
5878
+ "active",
5879
+ "paused",
5880
+ "ended"
5881
+ ]).describe("active = assigns + exposes new users; paused = serves existing assignments but issues no new ones; ended = read-only.");
5882
+ function registerTools$5(server, apiClient) {
5883
+ registerTool(server, apiClient, "amba_experiments_create", [
5884
+ "Create an A/B experiment: a stable `key`, a display `name`, and >= 2 weighted variants.",
5885
+ "End-users are bucketed into a STICKY variant the first time they request an assignment",
5886
+ "(via the SDK) using a deterministic hash over the variant weights; the bucket then never",
5887
+ "changes even if you re-weight later. The FIRST variant is the control for significance."
5888
+ ].join(" "), {
5889
+ project_id: z.string().describe("The Amba project ID."),
5890
+ key: z.string().describe("Stable experiment key (the SDK addresses the experiment by this)."),
5891
+ name: z.string().describe("Human-readable experiment name."),
5892
+ description: z.string().nullable().optional().describe("Optional description."),
5893
+ variants: z.array(variantSchema).min(2).describe("At least 2 weighted variants. First is the control."),
5894
+ status: STATUS_ENUM.optional().describe("Initial status. Defaults to \"active\".")
5895
+ }, async ({ project_id, key, name, description, variants, status }, { client }) => {
5896
+ const payload = {
5897
+ key,
5898
+ name,
5899
+ variants
5900
+ };
5901
+ if (description !== void 0) payload.description = description;
5902
+ if (status !== void 0) payload.status = status;
5903
+ const result = await client.post(`/projects/${encodeURIComponent(project_id)}/experiments`, payload);
5904
+ return { content: [{
5905
+ type: "text",
5906
+ text: JSON.stringify(result, null, 2)
5907
+ }] };
5908
+ });
5909
+ registerTool(server, apiClient, "amba_experiments_list", "List every experiment on a project (key, name, variants, status, timestamps), ordered by key.", { project_id: z.string().describe("The Amba project ID.") }, async ({ project_id }, { client }) => {
5910
+ const result = await client.get(`/projects/${encodeURIComponent(project_id)}/experiments`);
5911
+ return { content: [{
5912
+ type: "text",
5913
+ text: JSON.stringify(result, null, 2)
5914
+ }] };
5915
+ });
5916
+ registerTool(server, apiClient, "amba_experiments_get", "Get a single experiment by key (name, variants, status, timestamps). 404 if not found.", {
5917
+ project_id: z.string().describe("The Amba project ID."),
5918
+ key: z.string().describe("Experiment key.")
5919
+ }, async ({ project_id, key }, { client }) => {
5920
+ const result = await client.get(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}`);
5921
+ return { content: [{
5922
+ type: "text",
5923
+ text: JSON.stringify(result, null, 2)
5924
+ }] };
5925
+ });
5926
+ registerTool(server, apiClient, "amba_experiments_update", [
5927
+ "Update an experiment's name, description, variants, and/or status. Only the supplied",
5928
+ "fields change; variants (when supplied) replace the whole list. Re-weighting NEVER",
5929
+ "re-rolls already-assigned users (assignments are sticky); it only affects users assigned",
5930
+ "after the edit. Set status to \"paused\" to stop new assignments or \"ended\" to freeze it."
5931
+ ].join(" "), {
5932
+ project_id: z.string().describe("The Amba project ID."),
5933
+ key: z.string().describe("Experiment key to update."),
5934
+ name: z.string().optional().describe("New name."),
5935
+ description: z.string().nullable().optional().describe("New description; null to clear."),
5936
+ variants: z.array(variantSchema).min(2).optional().describe("Replacement variant list (>= 2). First is the control."),
5937
+ status: STATUS_ENUM.optional().describe("New status.")
5938
+ }, async ({ project_id, key, name, description, variants, status }, { client }) => {
5939
+ const payload = {};
5940
+ if (name !== void 0) payload.name = name;
5941
+ if (description !== void 0) payload.description = description;
5942
+ if (variants !== void 0) payload.variants = variants;
5943
+ if (status !== void 0) payload.status = status;
5944
+ const result = await client.patch(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}`, payload);
5945
+ return { content: [{
5946
+ type: "text",
5947
+ text: JSON.stringify(result, null, 2)
5948
+ }] };
5949
+ });
5950
+ registerTool(server, apiClient, "amba_experiments_delete", "Delete an experiment by key. Cascades its assignments + exposures. Hard delete.", {
5951
+ project_id: z.string().describe("The Amba project ID."),
5952
+ key: z.string().describe("Experiment key to delete.")
5953
+ }, async ({ project_id, key }, { client }) => {
5954
+ const result = await client.delete(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}`);
5955
+ return { content: [{
5956
+ type: "text",
5957
+ text: JSON.stringify(result ?? {
5958
+ key,
5959
+ deleted: true
5960
+ }, null, 2)
5961
+ }] };
5962
+ });
5963
+ registerTool(server, apiClient, "amba_experiments_results", [
5964
+ "Compute per-variant results for an experiment: assigned_count, exposed_count, and",
5965
+ "(when `conversion_event` is supplied) converted_count + conversion_rate — the distinct",
5966
+ "exposed users who emitted that analytics event at/after their assignment. Each non-control",
5967
+ "variant also gets a two-proportion z-test vs the control (first variant): a z_score and an",
5968
+ "approximate two-sided p_value. The control variant's z_score/p_value are null."
5969
+ ].join(" "), {
5970
+ project_id: z.string().describe("The Amba project ID."),
5971
+ key: z.string().describe("Experiment key."),
5972
+ conversion_event: z.string().optional().describe("Analytics event_name to treat as the conversion goal. Omit to get assigned/exposed counts only (conversions reported as 0).")
5973
+ }, async ({ project_id, key, conversion_event }, { client }) => {
5974
+ const query = {};
5975
+ if (conversion_event !== void 0) query.conversion_event = conversion_event;
5976
+ const result = await client.get(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}/results`, query);
5977
+ return { content: [{
5978
+ type: "text",
5979
+ text: JSON.stringify(result, null, 2)
5980
+ }] };
5981
+ });
5982
+ }
5983
+ //#endregion
5984
+ //#region src/tools/promotion.ts
5985
+ function registerTools$4(server, apiClient) {
5986
+ registerTool(server, apiClient, "amba_project_export", [
5987
+ "Export a declarative bundle of a project's reusable configuration so it can be re-created in another project (e.g. promote dev → prod) in one operation.",
5988
+ "The bundle includes (each section only when present): remote configs, collection SCHEMAS (column defs + indexes, no rows), content libraries + their items, and the full gamification set — currencies, achievements, streaks, challenges, leaderboards, and xp rules — as DEFINITIONS only.",
5989
+ "It carries NO secrets, NO per-user data, and NO end-user rows. Pass the returned `bundle` to `amba_project_import` against the target project."
5990
+ ].join(" "), { project_id: z.string().describe("The Amba project ID to export configuration FROM.") }, async ({ project_id }, { pat }) => {
5991
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/promotion/export`));
5992
+ });
5993
+ registerTool(server, apiClient, "amba_project_import", [
5994
+ "Import a declarative configuration bundle (from `amba_project_export`) into a project. Idempotently creates remote configs, collections, content libraries + items, and gamification definitions (currencies, achievements, streaks, challenges, leaderboards, xp rules).",
5995
+ "mode=\"skip_existing\" (default) leaves any entity that already exists (by name/key/code) untouched; mode=\"merge\" refreshes the definition of existing entities. Either mode is safe to run twice — re-import never duplicates.",
5996
+ "Returns a per-section summary of { created, skipped, updated }. Collections are create-if-absent only (an existing collection is skipped; alter the schema via the dedicated collection tools)."
5997
+ ].join(" "), {
5998
+ project_id: z.string().describe("The Amba project ID to import configuration INTO."),
5999
+ bundle: z.record(z.unknown()).describe("The bundle object returned by `amba_project_export` (its `data` field)."),
6000
+ mode: z.enum(["merge", "skip_existing"]).optional().describe("Conflict resolution. \"skip_existing\" (default) no-ops on a name/key/code conflict; \"merge\" updates the existing definition.")
6001
+ }, async ({ project_id, bundle, mode }, { pat }) => {
6002
+ const body = { bundle };
6003
+ if (mode !== void 0) body.mode = mode;
6004
+ return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/promotion/import`, { body }));
6005
+ });
6006
+ }
6007
+ //#endregion
5235
6008
  //#region src/tools/billing.ts
5236
6009
  /**
5237
6010
  * Static tier catalog. Mirrors the marketing-page structure shipped in
@@ -6993,7 +7766,7 @@ Admin tools authenticate the developer/agent (pass \`pat\` or send it as the inb
6993
7766
  | \`amba_collections_create\` | Create a typed collection. Pass \`shared: true\` for developer-seeded GLOBAL content (question banks, lookup tables) so \`user_id\` is nullable. | \`{ project_id, name: "todos", columns: [{ name: "title", type: "text", nullable: false }, { name: "done", type: "boolean", nullable: false, default: false }, { name: "due_at", type: "timestamptz", nullable: true }], shared: false }\` |
6994
7767
  | \`amba_collections_list\` | List collections in this project. | \`{ project_id }\` |
6995
7768
  | \`amba_collections_get\` | Read one collection's schema. | \`{ project_id, name: "todos" }\` |
6996
- | \`amba_collections_alter\` | Exactly ONE of: \`add_column\`, \`add_index\`, \`drop_column\`, or \`relax_user_id\` per call. \`relax_user_id: true\` converts an existing collection to shared (drops the \`user_id\` NOT NULL). | \`{ project_id, name: "todos", add_column: { name: "priority", type: "int", nullable: true } }\` |
7769
+ | \`amba_collections_alter\` | Exactly ONE of: \`add_column\`, \`add_index\`, \`drop_column\`, or \`relax_user_id\` per call. \`relax_user_id: true\` converts an existing collection to shared (drops the \`user_id\` NOT NULL). | \`{ project_id, name: "todos", add_column: { name: "priority", type: "integer", nullable: true } }\` |
6997
7770
  | \`amba_collections_delete\` | Drop the table (destructive). \`confirm\` must equal the collection name. | \`{ project_id, name: "todos", confirm: "todos" }\` |
6998
7771
  | \`amba_admin_insert_row\` | Insert one row as the developer (bypasses user-scope; \`user_id\` honored if present). | \`{ project_id, name: "todos", row: { title: "Sample", done: false } }\` |
6999
7772
  | \`amba_admin_insert_rows\` | Bulk-insert up to 500 rows in one atomic statement — the canonical seeding/migration path. \`on_conflict\`: \`"error"\` (default) or \`"skip"\`. | \`{ project_id, name: "questions", rows: [{ q: "..." }, { q: "..." }], on_conflict: "skip" }\` |
@@ -7007,7 +7780,7 @@ Admin tools authenticate the developer/agent (pass \`pat\` or send it as the inb
7007
7780
  | \`amba_client_find_rows\` | Filter / sort / paginate rows (SDK-shaped \`filter\`). | \`{ api_key, session_token, name: "todos", filter: {...}, order: ["created_at desc"], limit: 50 }\` |
7008
7781
  | \`amba_client_find_nearest_rows\` | Vector-similarity search (rows with a \`vector(<dim>)\` column). | \`{ api_key, session_token, name: "todos", column: "embedding", to_vector: [...], k: 10 }\` |
7009
7782
 
7010
- Column types: \`text\`, \`int\`, \`bigint\`, \`float\`, \`boolean\`, \`timestamptz\`, \`date\`, \`json\`, \`jsonb\`, \`uuid\`, \`vector(<dim>)\` (e.g. \`vector(1536)\` for OpenAI embeddings).
7783
+ Column types: \`text\`, \`integer\`, \`bigint\`, \`numeric\`, \`boolean\`, \`timestamptz\`, \`date\`, \`jsonb\`, \`uuid\`, \`vector(<dim>)\` (e.g. \`vector(1536)\` for OpenAI embeddings), plus array forms \`text[]\`, \`integer[]\`, \`bigint[]\`, \`numeric[]\`, \`boolean[]\`, \`uuid[]\`. Use \`integer\` (not \`int\`), \`numeric\` (not \`float\`/\`real\`/\`double\`), and \`jsonb\` (not \`json\`) — the validator rejects the aliases.
7011
7784
 
7012
7785
  ### Functions (serverless code)
7013
7786
 
@@ -7027,16 +7800,23 @@ Run user code in a sandbox triggered by HTTP, cron, or webhook. The function get
7027
7800
 
7028
7801
  ### AI prompts
7029
7802
 
7030
- Managed LLM templates: stored prompt with model + system message + variables, callable by name from the SDK. The actual LLM call is rewritten per-tenant — the customer's API keys (Anthropic / OpenAI) live in the tenant secrets, never on the device.
7803
+ Managed LLM templates: a stored prompt with provider + model + system message, invoked by name from the SDK. The actual LLM call is rewritten server-side per-tenant — the customer's provider API key (Anthropic / OpenAI / Mistral / Gemini) stays server-side, never on the device.
7804
+
7805
+ **Two steps, in order:** first register the provider key with \`amba_ai_providers_set\`, then create prompts against it. A prompt registered before its provider has a key still saves, but invocations fail with \`provider_not_configured\` (424) until the key is set.
7806
+
7807
+ > The provider key is **not** a function secret. \`amba_secrets_set\` writes function-scoped Worker secrets, which the AI gateway never reads. Provider keys live in a separate gateway-owned store and are set **only** via \`amba_ai_providers_set\`.
7031
7808
 
7032
7809
  | Tool | Purpose | Example args |
7033
7810
  | --- | --- | --- |
7034
- | \`amba_ai_prompts_create\` | Create a prompt template. | \`{ project_id, key: "summarize", model: "claude-opus-4-5", system: "Summarize the user's text in 2 sentences.", variables: ["text"] }\` |
7811
+ | \`amba_ai_providers_set\` | Register / rotate the upstream provider API key. **Do this first.** | \`{ project_id, provider: "anthropic", api_key: "sk-ant-..." }\` |
7812
+ | \`amba_ai_providers_list\` | List registered providers (\`configured\` = key set). | \`{ project_id }\` |
7813
+ | \`amba_ai_providers_delete\` | Revoke a provider key (fails if prompts still reference it). | \`{ project_id, provider: "anthropic" }\` |
7814
+ | \`amba_ai_prompts_create\` | Create a prompt template. \`client_invokable: true\` lets the device SDK invoke it directly. | \`{ project_id, name: "summarize", provider: "anthropic", model: "claude-opus-4-5", system_prompt: "Summarize the user's text in 2 sentences.", client_invokable: true }\` |
7035
7815
  | \`amba_ai_prompts_list\` | List prompts. | \`{ project_id }\` |
7036
- | \`amba_ai_prompts_get\` | Read one prompt. | \`{ project_id, key }\` |
7037
- | \`amba_ai_prompts_update\` | Edit a prompt. | \`{ project_id, key, system: "..." }\` |
7038
- | \`amba_ai_prompts_invoke\` | Invoke a prompt server-side (admin testing). | \`{ project_id, key, variables: { text: "..." } }\` |
7039
- | \`amba_ai_prompts_delete\` | Delete. | \`{ project_id, key }\` |
7816
+ | \`amba_ai_prompts_get\` | Read one prompt. | \`{ project_id, name }\` |
7817
+ | \`amba_ai_prompts_update\` | Edit a prompt (replaces all fields; bumps version). | \`{ project_id, name, provider, model, system_prompt: "..." }\` |
7818
+ | \`amba_ai_prompts_invoke\` | Invoke a prompt server-side (admin testing). \`messages\` is a provider-shaped array. | \`{ project_id, name, messages: [{ role: "user", content: "..." }] }\` |
7819
+ | \`amba_ai_prompts_delete\` | Delete. | \`{ project_id, name }\` |
7040
7820
 
7041
7821
  ### Analytics + events + sessions
7042
7822
 
@@ -7052,9 +7832,11 @@ Managed LLM templates: stored prompt with model + system message + variables, ca
7052
7832
 
7053
7833
  ### Secrets + configs + integrations
7054
7834
 
7835
+ Secrets here are **function-scoped** — they become environment bindings on your deployed functions. They are NOT where AI provider keys go (use \`amba_ai_providers_set\` for those — see AI prompts above).
7836
+
7055
7837
  | Tool | Purpose | Example args |
7056
7838
  | --- | --- | --- |
7057
- | \`amba_secrets_set\` | Set a tenant secret (encrypted at rest). | \`{ project_id, name: "OPENAI_API_KEY", value: "sk-..." }\` |
7839
+ | \`amba_secrets_set\` | Set a function-scoped secret (encrypted at rest; bound on the next deploy). | \`{ project_id, name: "STRIPE_WEBHOOK_SECRET", value: "whsec_..." }\` |
7058
7840
  | \`amba_secrets_get\` | Read a secret (returns \`"<redacted>"\` unless explicitly requested). | \`{ project_id, name }\` |
7059
7841
  | \`amba_secrets_list\` | List secret names. | \`{ project_id }\` |
7060
7842
  | \`amba_secrets_delete\` | Delete. | \`{ project_id, name }\` |
@@ -7113,9 +7895,9 @@ const newTodo = await Amba.collections.insert('todos', { title: 'Ship the app',
7113
7895
  await Amba.collections.update('todos', newTodo.id, { done: true });
7114
7896
  await Amba.collections.delete('todos', newTodo.id);
7115
7897
 
7116
- // AI — call a managed prompt
7898
+ // AI — call a managed prompt (prompt_slug names the registered prompt)
7117
7899
  const response = await Amba.ai.anthropic.messages.create({
7118
- prompt_key: 'summarize',
7900
+ prompt_slug: 'summarize',
7119
7901
  variables: { text: 'A long article about backend services …' },
7120
7902
  });
7121
7903
 
@@ -7183,8 +7965,7 @@ let showBeta = try await Amba.flags.get(name: "beta_feature")
7183
7965
  try await Amba.events.track("app_opened", properties: ["source": "deep_link"])
7184
7966
 
7185
7967
  let reply = try await Amba.ai.anthropic.messages.create(
7186
- promptKey: "summarize",
7187
- variables: ["text": "A long article..."]
7968
+ request: AiMessageRequest(promptSlug: "summarize", variables: ["text": "A long article..."])
7188
7969
  )
7189
7970
  \`\`\`
7190
7971
 
@@ -7241,7 +8022,7 @@ Batch.
7241
8022
  - [ ] Resend (transactional email)
7242
8023
  - [ ] Stripe (web payments / subscriptions)
7243
8024
  - [ ] Mixpanel / PostHog / Segment (analytics forwarding)
7244
- - [ ] OpenAI / Anthropic (LLM keys — required for \`Amba.ai.*\` calls)
8025
+ - [ ] OpenAI / Anthropic / Mistral / Gemini LLM keys (required for \`Amba.ai.*\` — set via \`amba_ai_providers_set\`, **not** \`amba_integrations_configure\`)
7245
8026
 
7246
8027
  6. **Feature flags:** seed any starter flags?
7247
8028
  - Yes — wire \`beta_feature\` (off by default) so I can ship the wiring before the feature exists
@@ -7256,7 +8037,7 @@ Batch.
7256
8037
  1. Before creating:
7257
8038
  - \`amba_collections_list\` — match on \`name\`. Collisions: never silently recreate (data loss). Offer \`amba_collections_alter\` to add new columns instead.
7258
8039
  - \`amba_functions_list\` — match on \`name\`. Collisions: ask to redeploy (with the new source) or skip.
7259
- - \`amba_ai_prompts_list\` — match on \`key\`. Same.
8040
+ - \`amba_ai_prompts_list\` — match on \`name\`. Same. (And \`amba_ai_providers_list\` — match on \`provider\`; re-running \`amba_ai_providers_set\` rotates the key in place.)
7260
8041
  - \`amba_integrations_list\` — match on \`provider\`. Same.
7261
8042
  - \`amba_configs_list\` — match on \`key\`. Same.
7262
8043
 
@@ -7438,11 +8219,15 @@ const TOOL_CATEGORY = {
7438
8219
  amba_developer_rotate_pat: "identity",
7439
8220
  amba_users_list: "identity",
7440
8221
  amba_list_users: "identity",
8222
+ amba_users_create: "identity",
8223
+ amba_create_user: "identity",
7441
8224
  amba_users_get: "identity",
7442
8225
  amba_users_delete: "identity",
7443
8226
  amba_users_export: "identity",
7444
8227
  amba_users_bulk_update: "identity",
8228
+ amba_users_create_cohort: "identity",
7445
8229
  amba_users_reset_sandbox: "identity",
8230
+ amba_users_reset_user: "identity",
7446
8231
  amba_users_events_export: "identity",
7447
8232
  amba_users_list_events: "identity",
7448
8233
  amba_api_keys_create: "identity",
@@ -7486,6 +8271,7 @@ const TOOL_CATEGORY = {
7486
8271
  amba_onboarding_get_stats: "engagement",
7487
8272
  amba_get_onboarding_stats: "engagement",
7488
8273
  amba_content_libraries_create: "engagement",
8274
+ amba_content_libraries_delete: "engagement",
7489
8275
  amba_create_content_library: "engagement",
7490
8276
  amba_content_list_libraries: "engagement",
7491
8277
  amba_content_items_add: "engagement",
@@ -7555,6 +8341,14 @@ const TOOL_CATEGORY = {
7555
8341
  amba_update_leaderboard: "gamification",
7556
8342
  amba_leaderboards_delete: "gamification",
7557
8343
  amba_delete_leaderboard: "gamification",
8344
+ amba_leagues_create: "gamification",
8345
+ amba_create_league: "gamification",
8346
+ amba_leagues_list: "gamification",
8347
+ amba_list_leagues: "gamification",
8348
+ amba_leagues_update: "gamification",
8349
+ amba_update_league: "gamification",
8350
+ amba_leagues_list_cohorts: "gamification",
8351
+ amba_list_league_cohorts: "gamification",
7558
8352
  amba_challenges_create: "gamification",
7559
8353
  amba_create_challenge: "gamification",
7560
8354
  amba_challenges_list: "gamification",
@@ -7580,6 +8374,7 @@ const TOOL_CATEGORY = {
7580
8374
  amba_currencies_spend: "economy",
7581
8375
  amba_currencies_get_transactions: "economy",
7582
8376
  amba_get_currency_transactions: "economy",
8377
+ amba_currencies_get_user_balance: "economy",
7583
8378
  amba_currency_grant_rules_create: "economy",
7584
8379
  amba_currency_grant_rules_list: "economy",
7585
8380
  amba_currency_grant_rules_delete: "economy",
@@ -7613,6 +8408,7 @@ const TOOL_CATEGORY = {
7613
8408
  amba_stores_delete_listing: "economy",
7614
8409
  amba_inventory_grant_item: "economy",
7615
8410
  amba_inventory_revoke_item: "economy",
8411
+ amba_entitlements_grant: "economy",
7616
8412
  amba_grant_item: "economy",
7617
8413
  amba_users_get_inventory: "economy",
7618
8414
  amba_get_user_inventory: "economy",
@@ -7673,6 +8469,7 @@ const TOOL_CATEGORY = {
7673
8469
  amba_moderation_list_trust: "social",
7674
8470
  amba_events_list: "analytics",
7675
8471
  amba_events_count: "analytics",
8472
+ amba_events_track: "analytics",
7676
8473
  amba_analytics_get: "analytics",
7677
8474
  amba_get_analytics: "analytics",
7678
8475
  amba_sessions_list: "analytics",
@@ -7683,6 +8480,14 @@ const TOOL_CATEGORY = {
7683
8480
  amba_funnels_update: "analytics",
7684
8481
  amba_funnels_delete: "analytics",
7685
8482
  amba_funnels_query: "analytics",
8483
+ amba_experiments_create: "analytics",
8484
+ amba_experiments_list: "analytics",
8485
+ amba_experiments_get: "analytics",
8486
+ amba_experiments_update: "analytics",
8487
+ amba_experiments_delete: "analytics",
8488
+ amba_experiments_results: "analytics",
8489
+ amba_project_export: "infrastructure",
8490
+ amba_project_import: "infrastructure",
7686
8491
  amba_projects_create: "infrastructure",
7687
8492
  amba_create_project: "infrastructure",
7688
8493
  amba_projects_list: "infrastructure",
@@ -7707,14 +8512,21 @@ const TOOL_CATEGORY = {
7707
8512
  amba_alter_collection: "infrastructure",
7708
8513
  amba_collections_delete: "infrastructure",
7709
8514
  amba_delete_collection: "infrastructure",
8515
+ amba_collections_reset_data: "infrastructure",
7710
8516
  amba_admin_insert_row: "infrastructure",
8517
+ amba_admin_update_row: "infrastructure",
8518
+ amba_admin_delete_row: "infrastructure",
8519
+ amba_admin_bulk_update: "infrastructure",
8520
+ amba_admin_bulk_delete: "infrastructure",
7711
8521
  amba_admin_list_rows: "infrastructure",
8522
+ amba_admin_aggregate_rows: "infrastructure",
7712
8523
  amba_client_insert_row: "infrastructure",
7713
8524
  amba_client_list_rows: "infrastructure",
7714
8525
  amba_client_get_row: "infrastructure",
7715
8526
  amba_client_count_rows: "infrastructure",
7716
8527
  amba_client_find_rows: "infrastructure",
7717
8528
  amba_client_find_nearest_rows: "infrastructure",
8529
+ amba_client_aggregate_rows: "infrastructure",
7718
8530
  amba_client_update_row: "infrastructure",
7719
8531
  amba_client_delete_row: "infrastructure",
7720
8532
  amba_functions_list: "infrastructure",
@@ -7753,6 +8565,8 @@ const TOOL_CATEGORY = {
7753
8565
  amba_domains_check: "infrastructure",
7754
8566
  amba_domains_purchase: "infrastructure",
7755
8567
  amba_domains_list: "infrastructure",
8568
+ amba_operations_get: "infrastructure",
8569
+ amba_operations_list: "infrastructure",
7756
8570
  amba_media_upload: "infrastructure",
7757
8571
  amba_upload_media: "infrastructure",
7758
8572
  amba_media_list: "infrastructure",
@@ -7784,6 +8598,9 @@ const TOOL_CATEGORY = {
7784
8598
  amba_integrations_patch: "infrastructure",
7785
8599
  amba_integrations_test: "infrastructure",
7786
8600
  amba_test_integration: "infrastructure",
8601
+ amba_ai_providers_set: "infrastructure",
8602
+ amba_ai_providers_list: "infrastructure",
8603
+ amba_ai_providers_delete: "infrastructure",
7787
8604
  amba_ai_prompts_create: "infrastructure",
7788
8605
  amba_create_ai_prompt: "infrastructure",
7789
8606
  amba_ai_prompts_list: "infrastructure",
@@ -7815,6 +8632,10 @@ const TOOL_CATEGORY = {
7815
8632
  amba_webhooks_deliveries_list: "infrastructure",
7816
8633
  amba_webhooks_deliveries_get: "infrastructure",
7817
8634
  amba_webhooks_deliveries_replay: "infrastructure",
8635
+ amba_events_catalog: "infrastructure",
8636
+ amba_control_webhooks_create: "infrastructure",
8637
+ amba_control_webhooks_list: "infrastructure",
8638
+ amba_control_webhooks_delete: "infrastructure",
7818
8639
  amba_email_templates_create: "engagement",
7819
8640
  amba_email_templates_list: "engagement",
7820
8641
  amba_email_templates_get: "engagement",
@@ -7850,7 +8671,13 @@ function getToolCategory(toolName) {
7850
8671
  * called against it once.
7851
8672
  */
7852
8673
  function registerAllTools(server, apiClient) {
7853
- registerTools$10(server, apiClient);
8674
+ registerTools$14(server, apiClient);
8675
+ registerTools$35(server, apiClient);
8676
+ registerTools$34(server, apiClient);
8677
+ registerTools$33(server, apiClient);
8678
+ registerTools$32(server, apiClient);
8679
+ registerTools$31(server, apiClient);
8680
+ registerTools$30(server, apiClient);
7854
8681
  registerTools$29(server, apiClient);
7855
8682
  registerTools$28(server, apiClient);
7856
8683
  registerTools$27(server, apiClient);
@@ -7866,10 +8693,10 @@ function registerAllTools(server, apiClient) {
7866
8693
  registerTools$17(server, apiClient);
7867
8694
  registerTools$16(server, apiClient);
7868
8695
  registerTools$15(server, apiClient);
7869
- registerTools$14(server, apiClient);
7870
8696
  registerTools$13(server, apiClient);
7871
8697
  registerTools$12(server, apiClient);
7872
8698
  registerTools$11(server, apiClient);
8699
+ registerTools$10(server, apiClient);
7873
8700
  registerTools$9(server, apiClient);
7874
8701
  registerTools$8(server, apiClient);
7875
8702
  registerTools$7(server, apiClient);