@layers/amba-mcp 4.0.3 → 4.0.4

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
@@ -611,7 +613,7 @@ function registerPublicTool(server, name, description, schema, handler, aliases
611
613
  }
612
614
  //#endregion
613
615
  //#region src/tools/projects.ts
614
- function registerTools$29(server, apiClient) {
616
+ function registerTools$33(server, apiClient) {
615
617
  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
618
  const result = await client.get("/projects");
617
619
  return { content: [{
@@ -718,7 +720,7 @@ function registerTools$29(server, apiClient) {
718
720
  }
719
721
  //#endregion
720
722
  //#region src/tools/push.ts
721
- function registerTools$28(server, apiClient) {
723
+ function registerTools$32(server, apiClient) {
722
724
  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
725
  project_id: z.string().describe("The project ID"),
724
726
  title: z.string().describe("Push notification title shown to the user"),
@@ -848,7 +850,7 @@ const segmentRulesSchema = z.object({
848
850
  operator: z.enum(["AND", "OR"]).describe("Logical operator combining conditions"),
849
851
  conditions: z.array(segmentConditionSchema).describe("Array of filter conditions")
850
852
  });
851
- function registerTools$27(server, apiClient) {
853
+ function registerTools$31(server, apiClient) {
852
854
  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
855
  project_id: z.string().describe("The project ID"),
854
856
  include_system: z.boolean().optional().describe("Include built-in system segments in the result. Defaults to false.")
@@ -933,7 +935,7 @@ const configConditionSchema = z.object({
933
935
  percentage: z.number().optional().describe("Percentage rollout (0-100)"),
934
936
  value: z.unknown().describe("Override value for this condition")
935
937
  });
936
- function registerTools$26(server, apiClient) {
938
+ function registerTools$30(server, apiClient) {
937
939
  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
940
  project_id: z.string().describe("The project ID"),
939
941
  include_system: z.boolean().optional().describe("Include platform-provided default config keys in the result. Defaults to false.")
@@ -1011,8 +1013,8 @@ const contentItemSchema = z.object({
1011
1013
  metadata: z.record(z.unknown()).optional().describe("Arbitrary metadata key-value pairs"),
1012
1014
  is_premium: z.boolean().optional().describe("Whether this content requires a premium entitlement")
1013
1015
  });
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.", {
1016
+ function registerTools$29(server, apiClient) {
1017
+ 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
1018
  project_id: z.string().describe("The project ID"),
1017
1019
  name: z.string().describe("Library name (e.g. \"Daily Motivation\", \"Workout Tips\")"),
1018
1020
  description: z.string().optional().describe("Description of the library content"),
@@ -1027,6 +1029,17 @@ function registerTools$25(server, apiClient) {
1027
1029
  text: JSON.stringify(result, null, 2)
1028
1030
  }] };
1029
1031
  }, ["amba_create_content_library"]);
1032
+ 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.", {
1033
+ project_id: z.string().describe("The project ID"),
1034
+ library_id: z.string().describe("The content library ID to delete"),
1035
+ 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).")
1036
+ }, async ({ project_id, library_id, cascade }, { client }) => {
1037
+ const result = await client.delete(`/projects/${project_id}/content/libraries/${library_id}`, cascade ? { cascade: "true" } : void 0);
1038
+ return { content: [{
1039
+ type: "text",
1040
+ text: JSON.stringify(result, null, 2)
1041
+ }] };
1042
+ });
1030
1043
  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
1044
  project_id: z.string().describe("The project ID"),
1032
1045
  library_id: z.string().describe("The content library ID"),
@@ -1070,7 +1083,7 @@ function registerTools$25(server, apiClient) {
1070
1083
  text: JSON.stringify(result, null, 2)
1071
1084
  }] };
1072
1085
  }, ["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 }) => {
1086
+ 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
1087
  const result = await client.get(`/projects/${project_id}/content/libraries`);
1075
1088
  return { content: [{
1076
1089
  type: "text",
@@ -1175,7 +1188,7 @@ function registerTools$25(server, apiClient) {
1175
1188
  }
1176
1189
  //#endregion
1177
1190
  //#region src/tools/streaks.ts
1178
- function registerTools$24(server, apiClient) {
1191
+ function registerTools$28(server, apiClient) {
1179
1192
  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
1193
  project_id: z.string().describe("The project ID"),
1181
1194
  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 +1280,11 @@ function registerTools$24(server, apiClient) {
1267
1280
  //#endregion
1268
1281
  //#region src/tools/integrations.ts
1269
1282
  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.", {
1283
+ function registerTools$27(server, apiClient) {
1284
+ 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
1285
  project_id: z.string().describe("The project ID"),
1273
1286
  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 }.")
1287
+ config: z.record(z.unknown()).describe("Provider-specific configuration. " + integrationConfigSummary())
1275
1288
  }, async ({ project_id, provider, config }, { client }) => {
1276
1289
  const result = await client.post(`/projects/${project_id}/integrations`, {
1277
1290
  provider,
@@ -1337,7 +1350,7 @@ const PERIOD_DAYS = {
1337
1350
  "30d": "30",
1338
1351
  "90d": "90"
1339
1352
  };
1340
- function registerTools$22(server, apiClient) {
1353
+ function registerTools$26(server, apiClient) {
1341
1354
  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
1355
  project_id: z.string().describe("The project ID"),
1343
1356
  period: z.enum([
@@ -1360,7 +1373,7 @@ function registerTools$22(server, apiClient) {
1360
1373
  }
1361
1374
  //#endregion
1362
1375
  //#region src/tools/users.ts
1363
- function registerTools$21(server, apiClient) {
1376
+ function registerTools$25(server, apiClient) {
1364
1377
  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
1378
  project_id: z.string().describe("The project ID"),
1366
1379
  limit: z.number().optional().describe("Maximum number of users to return (default 50)"),
@@ -1379,6 +1392,30 @@ function registerTools$21(server, apiClient) {
1379
1392
  text: JSON.stringify(result, null, 2)
1380
1393
  }] };
1381
1394
  }, ["amba_list_users"]);
1395
+ 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.", {
1396
+ project_id: z.string().describe("The project ID"),
1397
+ external_id: z.string().optional().describe("Your stable external identifier for this user (unique). Optional."),
1398
+ anonymous_id: z.string().optional().describe("Anonymous device id (unique). Optional; auto-generated if no identity given."),
1399
+ email: z.string().optional().describe("Email address. Optional."),
1400
+ phone: z.string().optional().describe("Phone number. Optional."),
1401
+ display_name: z.string().optional().describe("Display name. Optional."),
1402
+ avatar_url: z.string().optional().describe("Avatar URL. Optional."),
1403
+ properties: z.record(z.unknown()).optional().describe("Arbitrary user properties (JSON object). Optional.")
1404
+ }, async ({ project_id, external_id, anonymous_id, email, phone, display_name, avatar_url, properties }, { client }) => {
1405
+ const payload = {};
1406
+ if (external_id !== void 0) payload.external_id = external_id;
1407
+ if (anonymous_id !== void 0) payload.anonymous_id = anonymous_id;
1408
+ if (email !== void 0) payload.email = email;
1409
+ if (phone !== void 0) payload.phone = phone;
1410
+ if (display_name !== void 0) payload.display_name = display_name;
1411
+ if (avatar_url !== void 0) payload.avatar_url = avatar_url;
1412
+ if (properties !== void 0) payload.properties = properties;
1413
+ const result = await client.post(`/projects/${project_id}/users`, payload);
1414
+ return { content: [{
1415
+ type: "text",
1416
+ text: JSON.stringify(result, null, 2)
1417
+ }] };
1418
+ }, ["amba_create_user"]);
1382
1419
  registerTool(server, apiClient, "amba_users_get", "Get a single app user by id, with their streaks and entitlements joined in.", {
1383
1420
  project_id: z.string().describe("The project ID"),
1384
1421
  user_id: z.string().describe("The app user ID")
@@ -1638,12 +1675,18 @@ println("Current streak: \${streak.currentCount}")`,
1638
1675
  // For Expo apps, use @layers/amba-expo instead.
1639
1676
  import { Amba } from '@layers/amba-react-native';
1640
1677
 
1641
- // Configure once at app startup
1678
+ // Configure once at app startup. The first authenticated call lazily
1679
+ // establishes an anonymous session, so reads work without a manual sign-in.
1680
+ // Call signInAnonymously() explicitly if you want to control when the
1681
+ // anonymous user is created.
1642
1682
  await Amba.configure({
1643
1683
  projectId: 'YOUR_PROJECT_ID',
1644
- apiKey: 'YOUR_CLIENT_API_KEY',
1684
+ clientKey: 'YOUR_CLIENT_API_KEY',
1645
1685
  });
1646
1686
 
1687
+ // Optional — establish the session up front instead of lazily:
1688
+ // await Amba.auth.signInAnonymously();
1689
+
1647
1690
  // Track events
1648
1691
  await Amba.events.track('workout_completed', {
1649
1692
  duration_minutes: 30,
@@ -1665,9 +1708,11 @@ import { Amba } from '@layers/amba-expo';
1665
1708
 
1666
1709
  export default function RootLayout() {
1667
1710
  useEffect(() => {
1668
- Amba.configure({
1711
+ // configure() is async. Reads lazily establish an anonymous session on the
1712
+ // first authenticated call, so no manual sign-in is required to start.
1713
+ void Amba.configure({
1669
1714
  projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
1670
- apiKey: 'YOUR_CLIENT_API_KEY',
1715
+ clientKey: 'YOUR_CLIENT_API_KEY',
1671
1716
  });
1672
1717
  }, []);
1673
1718
 
@@ -1733,7 +1778,7 @@ final dailyLimit = await Amba.config.get<int>('daily_limit', defaultValue: 10);
1733
1778
  final streak = await Amba.streaks.get('daily_login');
1734
1779
  print('Current streak: \${streak.currentCount}');`
1735
1780
  };
1736
- function registerTools$20(server, apiClient) {
1781
+ function registerTools$24(server, apiClient) {
1737
1782
  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
1783
  "ios",
1739
1784
  "android",
@@ -1818,7 +1863,7 @@ const criteriaSchema = z.object({
1818
1863
  property_key: z.string().optional().describe("User property key (required for property_value type)"),
1819
1864
  target_value: z.number().describe("Target value to unlock the achievement")
1820
1865
  });
1821
- function registerTools$19(server, apiClient) {
1866
+ function registerTools$23(server, apiClient) {
1822
1867
  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
1868
  project_id: z.string().describe("The project ID"),
1824
1869
  key: z.string().describe("Unique key for this achievement (e.g. \"first_workout\", \"streak_master_7\")"),
@@ -1923,7 +1968,7 @@ function registerTools$19(server, apiClient) {
1923
1968
  }
1924
1969
  //#endregion
1925
1970
  //#region src/tools/challenges.ts
1926
- function registerTools$18(server, apiClient) {
1971
+ function registerTools$22(server, apiClient) {
1927
1972
  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
1973
  project_id: z.string().describe("The project ID"),
1929
1974
  name: z.string().describe("Challenge name (e.g. \"7-Day Fitness Sprint\", \"XP Weekend Blitz\")"),
@@ -2034,7 +2079,7 @@ function registerTools$18(server, apiClient) {
2034
2079
  }
2035
2080
  //#endregion
2036
2081
  //#region src/tools/economy.ts
2037
- function registerTools$17(server, apiClient) {
2082
+ function registerTools$21(server, apiClient) {
2038
2083
  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
2084
  project_id: z.string().describe("The project ID"),
2040
2085
  code: z.string().describe("Unique currency code (e.g. \"gold\", \"gems\", \"hearts\")"),
@@ -2069,38 +2114,42 @@ function registerTools$17(server, apiClient) {
2069
2114
  text: JSON.stringify(result, null, 2)
2070
2115
  }] };
2071
2116
  }, ["amba_list_currencies"]);
2072
- registerTool(server, apiClient, "amba_currencies_grant", "Grant virtual currency to a specific user. Useful for rewards, promotions, or admin adjustments.", {
2117
+ 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
2118
  project_id: z.string().describe("The project ID"),
2074
2119
  app_user_id: z.string().describe("The user to grant currency to"),
2075
2120
  currency_code: z.string().describe("Currency code (e.g. \"gold\")"),
2076
2121
  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 }) => {
2122
+ reason: z.string().optional().describe("Reason for the grant (for audit trail)"),
2123
+ 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).")
2124
+ }, async ({ project_id, app_user_id, currency_code, amount, reason, idempotency_key }, { client }) => {
2079
2125
  const payload = {
2080
2126
  app_user_id,
2081
2127
  currency_code,
2082
2128
  amount
2083
2129
  };
2084
2130
  if (reason !== void 0) payload.reason = reason;
2131
+ if (idempotency_key !== void 0) payload.idempotency_key = idempotency_key;
2085
2132
  const result = await client.post(`/projects/${project_id}/currencies/grant`, payload);
2086
2133
  return { content: [{
2087
2134
  type: "text",
2088
2135
  text: JSON.stringify(result, null, 2)
2089
2136
  }] };
2090
2137
  }, ["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.", {
2138
+ 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
2139
  project_id: z.string().describe("The project ID"),
2093
2140
  app_user_id: z.string().describe("The user to debit currency from"),
2094
2141
  currency_code: z.string().describe("Currency code (e.g. \"gold\")"),
2095
2142
  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 }) => {
2143
+ reason: z.string().optional().describe("Reason for the spend (stored as reference_id, useful for the audit trail)"),
2144
+ 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.")
2145
+ }, async ({ project_id, app_user_id, currency_code, amount, reason, idempotency_key }, { client }) => {
2098
2146
  const payload = {
2099
2147
  app_user_id,
2100
2148
  currency_code,
2101
2149
  amount
2102
2150
  };
2103
2151
  if (reason !== void 0) payload.reason = reason;
2152
+ if (idempotency_key !== void 0) payload.idempotency_key = idempotency_key;
2104
2153
  const result = await client.post(`/projects/${project_id}/currencies/spend`, payload);
2105
2154
  return { content: [{
2106
2155
  type: "text",
@@ -2566,8 +2615,8 @@ function registerTools$17(server, apiClient) {
2566
2615
  }
2567
2616
  //#endregion
2568
2617
  //#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.", {
2618
+ function registerTools$20(server, apiClient) {
2619
+ 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
2620
  project_id: z.string().describe("The project ID"),
2572
2621
  name: z.string().describe("Leaderboard name (e.g. \"Top XP Earners\", \"Weekly Streak Leaders\")"),
2573
2622
  metric: z.enum([
@@ -2612,7 +2661,7 @@ function registerTools$16(server, apiClient) {
2612
2661
  text: JSON.stringify(result, null, 2)
2613
2662
  }] };
2614
2663
  }, ["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 }) => {
2664
+ 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
2665
  const result = await client.get(`/projects/${project_id}/leaderboards`);
2617
2666
  return { content: [{
2618
2667
  type: "text",
@@ -2671,8 +2720,80 @@ function registerTools$16(server, apiClient) {
2671
2720
  }, ["amba_delete_leaderboard"]);
2672
2721
  }
2673
2722
  //#endregion
2723
+ //#region src/tools/leagues.ts
2724
+ /**
2725
+ * Leagues — the Duolingo-style promote/demote tiers (Bronze/Silver/Gold/…).
2726
+ * Distinct from leaderboards (a single ranked list): a league has weekly
2727
+ * cohorts that the rollover workflow reshuffles. These tools provision and
2728
+ * inspect leagues; the admin routes are mounted at
2729
+ * `/v1/admin/projects/:projectId/leagues`. Members are assigned by the weekly
2730
+ * rollover (the registered Temporal schedule) and score live off `xp_awarded`
2731
+ * engagement events.
2732
+ */
2733
+ function registerTools$19(server, apiClient) {
2734
+ 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.", {
2735
+ project_id: z.string().describe("The project ID"),
2736
+ name: z.string().describe("League tier name, e.g. \"Bronze\", \"Silver\", \"Gold\""),
2737
+ tier_order: z.number().describe("Tier rank, 1 = lowest/entry tier. Higher tiers have higher tier_order."),
2738
+ promote_count: z.number().optional().describe("How many top members of a cohort promote each week (default 5)"),
2739
+ demote_count: z.number().optional().describe("How many bottom members of a cohort demote each week (default 5)"),
2740
+ cohort_size: z.number().optional().describe("Target members per weekly cohort (default 30)")
2741
+ }, async ({ project_id, name, tier_order, promote_count, demote_count, cohort_size }, { client }) => {
2742
+ const payload = {
2743
+ name,
2744
+ tier_order
2745
+ };
2746
+ if (promote_count !== void 0) payload.promote_count = promote_count;
2747
+ if (demote_count !== void 0) payload.demote_count = demote_count;
2748
+ if (cohort_size !== void 0) payload.cohort_size = cohort_size;
2749
+ const result = await client.post(`/projects/${project_id}/leagues`, payload);
2750
+ return { content: [{
2751
+ type: "text",
2752
+ text: JSON.stringify(result, null, 2)
2753
+ }] };
2754
+ }, ["amba_create_league"]);
2755
+ 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 }) => {
2756
+ const result = await client.get(`/projects/${project_id}/leagues`);
2757
+ return { content: [{
2758
+ type: "text",
2759
+ text: JSON.stringify(result, null, 2)
2760
+ }] };
2761
+ }, ["amba_list_leagues"]);
2762
+ 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).", {
2763
+ project_id: z.string().describe("The project ID"),
2764
+ league_id: z.string().describe("The league ID"),
2765
+ name: z.string().optional().describe("New tier name"),
2766
+ promote_count: z.number().optional().describe("New weekly promote count"),
2767
+ demote_count: z.number().optional().describe("New weekly demote count"),
2768
+ cohort_size: z.number().optional().describe("New target cohort size"),
2769
+ is_active: z.boolean().optional().describe("Enable/disable the tier")
2770
+ }, async ({ project_id, league_id, name, promote_count, demote_count, cohort_size, is_active }, { client }) => {
2771
+ const payload = {};
2772
+ if (name !== void 0) payload.name = name;
2773
+ if (promote_count !== void 0) payload.promote_count = promote_count;
2774
+ if (demote_count !== void 0) payload.demote_count = demote_count;
2775
+ if (cohort_size !== void 0) payload.cohort_size = cohort_size;
2776
+ if (is_active !== void 0) payload.is_active = is_active;
2777
+ const result = await client.patch(`/projects/${project_id}/leagues/${league_id}`, payload);
2778
+ return { content: [{
2779
+ type: "text",
2780
+ text: JSON.stringify(result, null, 2)
2781
+ }] };
2782
+ }, ["amba_update_league"]);
2783
+ 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.", {
2784
+ project_id: z.string().describe("The project ID"),
2785
+ league_id: z.string().describe("The league ID")
2786
+ }, async ({ project_id, league_id }, { client }) => {
2787
+ const result = await client.get(`/projects/${project_id}/leagues/${league_id}/cohorts/current`);
2788
+ return { content: [{
2789
+ type: "text",
2790
+ text: JSON.stringify(result, null, 2)
2791
+ }] };
2792
+ }, ["amba_list_league_cohorts"]);
2793
+ }
2794
+ //#endregion
2674
2795
  //#region src/tools/platform.ts
2675
- function registerTools$15(server, apiClient) {
2796
+ function registerTools$18(server, apiClient) {
2676
2797
  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
2798
  project_id: z.string().describe("The project ID"),
2678
2799
  name: z.string().describe("Flow name (e.g. \"Welcome Flow\", \"Premium Onboarding\")"),
@@ -3248,7 +3369,7 @@ function registerTools$15(server, apiClient) {
3248
3369
  }
3249
3370
  //#endregion
3250
3371
  //#region src/tools/social.ts
3251
- function registerTools$14(server, apiClient) {
3372
+ function registerTools$17(server, apiClient) {
3252
3373
  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
3374
  const result = await client.get(`/projects/${project_id}/friends/stats`);
3254
3375
  return { content: [{
@@ -3573,7 +3694,7 @@ function registerTools$14(server, apiClient) {
3573
3694
  }
3574
3695
  //#endregion
3575
3696
  //#region src/tools/xp.ts
3576
- function registerTools$13(server, apiClient) {
3697
+ function registerTools$16(server, apiClient) {
3577
3698
  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
3699
  project_id: z.string().describe("The project ID"),
3579
3700
  name: z.string().describe("Rule name (e.g. \"Workout Completed\", \"Daily Login Bonus\")"),
@@ -3696,7 +3817,7 @@ function registerTools$13(server, apiClient) {
3696
3817
  }
3697
3818
  //#endregion
3698
3819
  //#region src/tools/events.ts
3699
- function registerTools$12(server, apiClient) {
3820
+ function registerTools$15(server, apiClient) {
3700
3821
  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
3822
  project_id: z.string().describe("The project ID"),
3702
3823
  since: z.string().optional().describe("ISO-8601 lower bound for occurred_at. Defaults to 24h ago."),
@@ -3763,7 +3884,7 @@ const funnelStepSchema = z.object({
3763
3884
  event: z.string().describe("Event name to match (engagement event_name)."),
3764
3885
  filters: z.array(funnelFilterSchema).optional().describe("Optional property predicates, ANDed together, that an event must satisfy.")
3765
3886
  });
3766
- function registerTools$11(server, apiClient) {
3887
+ function registerTools$14(server, apiClient) {
3767
3888
  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
3889
  const result = await client.get(`/projects/${project_id}/funnels`);
3769
3890
  return { content: [{
@@ -4029,7 +4150,7 @@ function enrichedAuthResult(result, agentInstructions) {
4029
4150
  status: result.status
4030
4151
  });
4031
4152
  }
4032
- function registerTools$10(server, apiClient) {
4153
+ function registerTools$13(server, apiClient) {
4033
4154
  registerPublicTool(server, "amba_developer_signup", [
4034
4155
  "Create a new Amba developer account. Returns a long-lived Personal Access Token (PAT)",
4035
4156
  "plus a real isolated Amba project (provisioning asynchronously), plus ready-to-paste",
@@ -4163,6 +4284,18 @@ async function adminFetch(apiClient, options) {
4163
4284
  body
4164
4285
  };
4165
4286
  }
4287
+ const CATALOG_SHAPE_KEYS = [
4288
+ "is_published",
4289
+ "published_at",
4290
+ "is_public",
4291
+ "published"
4292
+ ];
4293
+ function catalogShapeWarning(row) {
4294
+ const hit = CATALOG_SHAPE_KEYS.filter((k) => k in row);
4295
+ if (hit.length === 0) return null;
4296
+ 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)";
4297
+ 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(" ");
4298
+ }
4166
4299
  async function clientFetch(apiClient, options) {
4167
4300
  let url = `${apiClient.getApiRoot()}/client${options.path}`;
4168
4301
  if (options.query && Object.keys(options.query).length > 0) url += `?${new URLSearchParams(options.query).toString()}`;
@@ -4200,7 +4333,7 @@ const MISSING_API_KEY_ERROR = jsonResult$1({
4200
4333
  });
4201
4334
  const columnSchema = z.object({
4202
4335
  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."),
4336
+ 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
4337
  nullable: z.boolean().optional().describe("Whether the column accepts NULL. Defaults to true."),
4205
4338
  default: z.unknown().optional().describe("Default SQL literal (e.g. \"NOW()\", 0, \"''\"). Optional."),
4206
4339
  dimensions: z.number().optional().describe("Required for `vector` columns; ignored otherwise.")
@@ -4226,7 +4359,7 @@ const sdkOrderSchema = z.array(z.object({
4226
4359
  })).describe("Ordering. `[{column: \"created_at\", direction: \"desc\"}, ...]`.");
4227
4360
  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
4361
  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) {
4362
+ function registerTools$12(server, apiClient) {
4230
4363
  registerTool(server, apiClient, "amba_collections_create", [
4231
4364
  "Create a new collection (schema-first Postgres table) in a project.",
4232
4365
  "The DDL is emitted server-side and applied via a Temporal saga.",
@@ -4349,19 +4482,39 @@ function registerTools$9(server, apiClient) {
4349
4482
  registerTool(server, apiClient, "amba_admin_insert_row", [
4350
4483
  "Insert a row directly into a collection from the developer/agent side — BYPASSES auto-RLS.",
4351
4484
  "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).",
4485
+ "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
4486
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4353
- "Errors: 400 INVALID_COLUMN, 404 COLLECTION_NOT_FOUND, 500 CREATE_FAILED."
4487
+ "Errors: 400 INVALID_COLUMN / INVALID_CONFLICT_TARGET, 404 COLLECTION_NOT_FOUND, 500 CREATE_FAILED."
4354
4488
  ].join(" "), {
4355
4489
  project_id: z.string().describe("The project ID."),
4356
4490
  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, {
4491
+ row: z.record(z.unknown()).describe("Column-value map for the new row. Server-managed columns are stripped."),
4492
+ on_conflict: z.enum([
4493
+ "error",
4494
+ "ignore",
4495
+ "update"
4496
+ ]).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."),
4497
+ 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\"."),
4498
+ 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.")
4499
+ }, async ({ project_id, name, row, on_conflict, conflict_target, return_minimal }, { pat }) => {
4500
+ const query = {};
4501
+ if (on_conflict !== void 0) query.on_conflict = on_conflict;
4502
+ if (conflict_target !== void 0) query.conflict_target = conflict_target.join(",");
4503
+ if (return_minimal) query.return = "minimal";
4504
+ const result = await adminFetch(apiClient, {
4360
4505
  method: "POST",
4361
4506
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
4362
4507
  body: row,
4508
+ query: Object.keys(query).length > 0 ? query : void 0,
4363
4509
  bearer: pat
4364
- }));
4510
+ });
4511
+ const warning = catalogShapeWarning(row);
4512
+ const base = passthroughResult(result);
4513
+ if (warning) base.content.unshift({
4514
+ type: "text",
4515
+ text: warning
4516
+ });
4517
+ return base;
4365
4518
  });
4366
4519
  registerTool(server, apiClient, "amba_admin_insert_rows", [
4367
4520
  "Bulk-insert up to 500 rows into a collection in a single atomic statement — BYPASSES auto-RLS.",
@@ -4374,20 +4527,22 @@ function registerTools$9(server, apiClient) {
4374
4527
  project_id: z.string().describe("The project ID."),
4375
4528
  name: z.string().describe("Collection name."),
4376
4529
  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 }) => {
4530
+ on_conflict: z.enum(["error", "skip"]).optional().describe("Conflict policy. \"error\" (default) fails the batch; \"skip\" ignores conflicts."),
4531
+ 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.")
4532
+ }, async ({ project_id, name, rows, on_conflict, return_minimal }, { pat }) => {
4379
4533
  const body = { rows };
4380
4534
  if (on_conflict !== void 0) body.on_conflict = on_conflict;
4381
4535
  return passthroughResult(await adminFetch(apiClient, {
4382
4536
  method: "POST",
4383
4537
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows:batch`,
4384
4538
  body,
4539
+ query: return_minimal ? { return: "minimal" } : void 0,
4385
4540
  bearer: pat
4386
4541
  }));
4387
4542
  });
4388
4543
  registerTool(server, apiClient, "amba_admin_list_rows", [
4389
4544
  "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.",
4545
+ "Default behavior EXCLUDES soft-deleted rows; pass `include_deleted=true` to include them (e.g. debugging \"where did the row go\").",
4391
4546
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4392
4547
  "Supports the server FindQuery DSL via `where` + `order` + `limit` + `offset` + `cursor` + `select`."
4393
4548
  ].join(" "), {
@@ -4399,7 +4554,7 @@ function registerTools$9(server, apiClient) {
4399
4554
  offset: z.number().optional().describe("Offset for pagination."),
4400
4555
  cursor: z.string().optional().describe("Opaque cursor returned by a prior page."),
4401
4556
  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.")
4557
+ include_deleted: z.boolean().optional().describe("Whether to include soft-deleted rows. Admin default is FALSE.")
4403
4558
  }, async ({ project_id, name, where, order, limit, offset, cursor, select, include_deleted }, { pat }) => {
4404
4559
  const findQuery = {};
4405
4560
  if (where !== void 0) findQuery.where = where;
@@ -4416,21 +4571,67 @@ function registerTools$9(server, apiClient) {
4416
4571
  bearer: pat
4417
4572
  }));
4418
4573
  });
4574
+ registerTool(server, apiClient, "amba_admin_aggregate_rows", [
4575
+ "Run a group-by aggregation over a collection from the admin side — BYPASSES auto-RLS.",
4576
+ "Supports count / sum / avg / min / max, optionally grouped by one or more columns. count without a column = COUNT(*).",
4577
+ "Default EXCLUDES soft-deleted rows; pass include_deleted=true to include them.",
4578
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4579
+ "Returns one row per group (or a single row when group_by is omitted), with each aggregation under its alias."
4580
+ ].join(" "), {
4581
+ project_id: z.string().describe("The project ID."),
4582
+ name: z.string().describe("Collection name."),
4583
+ select: z.array(z.object({
4584
+ fn: z.enum([
4585
+ "count",
4586
+ "sum",
4587
+ "avg",
4588
+ "min",
4589
+ "max"
4590
+ ]),
4591
+ column: z.string().optional().describe("Column to aggregate. Required for sum/avg/min/max; omit for COUNT(*)."),
4592
+ as: z.string().optional().describe("Output alias (defaults to fn or fn_column).")
4593
+ })).min(1).describe("One or more aggregations to compute."),
4594
+ group_by: z.union([z.string(), z.array(z.string())]).optional().describe("Column(s) to group by. Omit for a whole-collection aggregate."),
4595
+ where: adminFindWhereSchema.optional(),
4596
+ include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default FALSE.")
4597
+ }, async ({ project_id, name, select, group_by, where, include_deleted }, { pat }) => {
4598
+ const body = { select };
4599
+ if (group_by !== void 0) body.group_by = group_by;
4600
+ if (where !== void 0) body.where = where;
4601
+ if (include_deleted !== void 0) body.includeDeleted = include_deleted;
4602
+ return passthroughResult(await adminFetch(apiClient, {
4603
+ method: "POST",
4604
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows/aggregate`,
4605
+ body,
4606
+ bearer: pat
4607
+ }));
4608
+ });
4419
4609
  server.tool("amba_client_insert_row", [
4420
4610
  "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.",
4611
+ "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
4612
  "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."
4613
+ "Errors: 400 INVALID_COLUMN / INVALID_CONFLICT_TARGET, 401 if api_key/session missing, 404 COLLECTION_NOT_FOUND."
4423
4614
  ].join(" "), {
4424
4615
  name: z.string().describe("Collection name."),
4425
4616
  row: z.record(z.unknown()).describe("Column-value map. Server-managed columns and user_id are stripped/overridden."),
4617
+ on_conflict: z.enum([
4618
+ "error",
4619
+ "ignore",
4620
+ "update"
4621
+ ]).optional().describe("Conflict policy. \"error\" (default), \"ignore\" (return existing unchanged), or \"update\" (merge). Requires conflict_target."),
4622
+ conflict_target: z.array(z.string()).optional().describe("Unique-index columns to conflict on (e.g. [\"user_id\",\"kind\"]). Required for ignore/update."),
4426
4623
  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
4624
  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 }) => {
4625
+ }, async ({ name, row, on_conflict, conflict_target, api_key, session_token }) => {
4429
4626
  if (!api_key) return MISSING_API_KEY_ERROR;
4627
+ const query = {};
4628
+ if (on_conflict !== void 0) query.on_conflict = on_conflict;
4629
+ if (conflict_target !== void 0) query.conflict_target = conflict_target.join(",");
4430
4630
  return passthroughResult(await clientFetch(apiClient, {
4431
4631
  method: "POST",
4432
4632
  path: `/collections/${encodeURIComponent(name)}`,
4433
4633
  body: row,
4634
+ ...Object.keys(query).length > 0 && { query },
4434
4635
  apiKey: api_key,
4435
4636
  ...session_token !== void 0 && { sessionToken: session_token }
4436
4637
  }));
@@ -4488,6 +4689,7 @@ function registerTools$9(server, apiClient) {
4488
4689
  });
4489
4690
  server.tool("amba_client_update_row", [
4490
4691
  "Update a single row by id. Auto-RLS — only succeeds if the row belongs to the signed-in app_user.",
4692
+ "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
4693
  "For bulk updates with a where-clause, omit `id` and pass `where` + `set`.",
4492
4694
  "Server-managed columns and user_id are rejected with 400 INVALID_COLUMN.",
4493
4695
  "Authenticates as the end-user — pass `api_key` + `session_token`."
@@ -4496,15 +4698,19 @@ function registerTools$9(server, apiClient) {
4496
4698
  id: z.string().optional().describe("Row UUID for single-row update. Omit to bulk update via `where`."),
4497
4699
  where: adminFindWhereSchema.optional().describe("Required for bulk update (when `id` is omitted). `where: {}` matches all rows the user owns."),
4498
4700
  set: setSchema.describe("Column-value map to write. Required."),
4701
+ 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
4702
  limit: z.number().optional().describe("Bulk-update cap (default 1000, max 10000). Ignored when `id` is set."),
4500
4703
  api_key: z.string().describe("Project client X-Api-Key."),
4501
4704
  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 }) => {
4705
+ }, async ({ name, id, where, set, expected, limit, api_key, session_token }) => {
4503
4706
  if (!api_key) return MISSING_API_KEY_ERROR;
4504
4707
  if (id !== void 0) return passthroughResult(await clientFetch(apiClient, {
4505
4708
  method: "PATCH",
4506
4709
  path: `/collections/${encodeURIComponent(name)}/${encodeURIComponent(id)}`,
4507
- body: { set },
4710
+ body: expected !== void 0 ? {
4711
+ set,
4712
+ expected
4713
+ } : { set },
4508
4714
  apiKey: api_key,
4509
4715
  ...session_token !== void 0 && { sessionToken: session_token }
4510
4716
  }));
@@ -4575,24 +4781,32 @@ function registerTools$9(server, apiClient) {
4575
4781
  });
4576
4782
  server.tool("amba_client_find_rows", [
4577
4783
  "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.",
4784
+ "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: {...}}`.",
4785
+ "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.",
4786
+ "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
4787
  "Authenticates as the end-user — pass `api_key` + `session_token`."
4581
4788
  ].join(" "), {
4582
4789
  name: z.string().describe("Collection name."),
4583
4790
  filter: sdkFilterSchema.optional(),
4584
4791
  order: sdkOrderSchema.optional(),
4792
+ search: z.object({
4793
+ q: z.string().describe("Search text."),
4794
+ columns: z.array(z.string()).min(1).describe("Text columns to search."),
4795
+ fuzzy: z.boolean().optional().describe("Typo-tolerant trigram match + relevance ranking. Default false (substring)."),
4796
+ threshold: z.number().optional().describe("Fuzzy only: similarity threshold 0..1 (default 0.3).")
4797
+ }).optional().describe("Keyword / fuzzy text search across columns."),
4585
4798
  limit: z.number().optional().describe("Max rows."),
4586
4799
  cursor: z.string().optional().describe("Opaque cursor returned by a prior page."),
4587
4800
  select: z.array(z.string()).optional().describe("Subset of columns to return."),
4588
4801
  include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default FALSE."),
4589
4802
  api_key: z.string().describe("Project client X-Api-Key."),
4590
4803
  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 }) => {
4804
+ }, async ({ name, filter, order, search, limit, cursor, select, include_deleted, api_key, session_token }) => {
4592
4805
  if (!api_key) return MISSING_API_KEY_ERROR;
4593
4806
  const body = {};
4594
4807
  if (filter !== void 0) body.filter = filter;
4595
4808
  if (order !== void 0) body.order = order;
4809
+ if (search !== void 0) body.search = search;
4596
4810
  if (limit !== void 0) body.limit = limit;
4597
4811
  if (cursor !== void 0) body.cursor = cursor;
4598
4812
  if (select !== void 0) body.select = select;
@@ -4764,24 +4978,27 @@ function jsonResult(payload) {
4764
4978
  * of a generic upstream 400.
4765
4979
  */
4766
4980
  const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
4767
- function registerTools$8(server, apiClient) {
4981
+ function registerTools$11(server, apiClient) {
4768
4982
  registerTool(server, apiClient, "amba_functions_deploy", [
4769
4983
  "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
4984
  "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
4985
  "Optional `rate_limit` declares a per-function rate-limit config (validated server-side). Pass null/omit for no rate limit.",
4986
+ "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
4987
  "Returns the function deployment row + the public URL (`fn_url`: `https://{project_slug}.fn.amba.host/{name}`)."
4773
4988
  ].join(" "), {
4774
4989
  project_id: z.string().describe("The Amba project ID."),
4775
4990
  name: z.string().describe("Function name. Lowercase, /^[a-z][a-z0-9_-]*$/, ≤58 chars."),
4776
4991
  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 }) => {
4992
+ rate_limit: z.unknown().optional().describe("Optional rate-limit config object. Shape: see @layers/amba-shared:RateLimitConfig."),
4993
+ 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.")
4994
+ }, async ({ project_id, name, code, rate_limit, public: isPublic }, { pat }) => {
4779
4995
  const byteLen = Buffer.byteLength(code, "utf8");
4780
4996
  if (byteLen > MAX_BUNDLE_BYTES) throw new Error(`Bundle size ${byteLen} bytes exceeds ${MAX_BUNDLE_BYTES}-byte cap (10 MiB).`);
4781
4997
  const form = new FormData();
4782
4998
  form.append("script", new Blob([code], { type: "application/javascript" }), "bundle.js");
4783
4999
  const metadata = { name };
4784
5000
  if (rate_limit !== void 0 && rate_limit !== null) metadata.rate_limit = rate_limit;
5001
+ if (isPublic === true) metadata.public = true;
4785
5002
  form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
4786
5003
  return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/functions/deploy`, { formData: form }));
4787
5004
  }, ["amba_deploy_function"]);
@@ -4889,7 +5106,7 @@ function registerTools$8(server, apiClient) {
4889
5106
  }
4890
5107
  //#endregion
4891
5108
  //#region src/tools/sites.ts
4892
- function registerTools$7(server, apiClient) {
5109
+ function registerTools$10(server, apiClient) {
4893
5110
  registerTool(server, apiClient, "amba_sites_deploy", [
4894
5111
  "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
5112
  "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 +5198,7 @@ function registerTools$7(server, apiClient) {
4981
5198
  }
4982
5199
  //#endregion
4983
5200
  //#region src/tools/domains.ts
4984
- function registerTools$6(server, apiClient) {
5201
+ function registerTools$9(server, apiClient) {
4985
5202
  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
5203
  project_id: z.string().describe("The Amba project ID."),
4987
5204
  query: z.string().describe("A keyword (e.g. \"getunbury\") or a full domain (e.g. \"unbury.com\")."),
@@ -5037,7 +5254,7 @@ function registerTools$6(server, apiClient) {
5037
5254
  }
5038
5255
  //#endregion
5039
5256
  //#region src/tools/secrets.ts
5040
- function registerTools$5(server, apiClient) {
5257
+ function registerTools$8(server, apiClient) {
5041
5258
  registerTool(server, apiClient, "amba_secrets_set", [
5042
5259
  "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
5260
  "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 +5324,26 @@ function registerTools$5(server, apiClient) {
5107
5324
  }
5108
5325
  //#endregion
5109
5326
  //#region src/tools/ai-prompts-admin.ts
5110
- const PROVIDER_ENUM = z.enum(["anthropic", "openai"]);
5327
+ const PROVIDER_ENUM$1 = z.enum([
5328
+ "anthropic",
5329
+ "openai",
5330
+ "mistral"
5331
+ ]);
5111
5332
  const RATE_LIMIT_SHAPE = z.object({
5112
5333
  window: z.string().optional().describe("Window length (e.g. \"60s\", \"1m\", \"1h\"). Defaults to \"60s\"."),
5113
5334
  max: z.number().int().min(1).optional().describe("Max invocations per window. Defaults to 20."),
5114
5335
  key: z.string().optional().describe("Bucketing key. Typically \"user_id\" or \"session_id\". Defaults to \"user_id\".")
5115
5336
  }).strict();
5116
- function registerTools$4(server, apiClient) {
5337
+ function registerTools$7(server, apiClient) {
5117
5338
  registerTool(server, apiClient, "amba_ai_prompts_create", [
5118
5339
  "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\").",
5340
+ "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
5341
  "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
5342
  "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
5343
  ].join(" "), {
5123
5344
  project_id: z.string().describe("The Amba project ID."),
5124
5345
  name: z.string().describe("Prompt name. /^[a-z][a-z0-9_-]{0,127}$/."),
5125
- provider: PROVIDER_ENUM.describe("AI provider. Must already be registered."),
5346
+ provider: PROVIDER_ENUM$1.describe("AI provider. Must already be registered via `amba_ai_providers_set`."),
5126
5347
  model: z.string().describe("Provider-native model id."),
5127
5348
  system_prompt: z.string().nullable().optional().describe("System prompt sent on every invocation. Omit or null for no system prompt."),
5128
5349
  max_tokens: z.number().int().min(1).max(2e5).optional().describe("Max output tokens per invocation. Defaults to 4096."),
@@ -5152,7 +5373,7 @@ function registerTools$4(server, apiClient) {
5152
5373
  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
5374
  project_id: z.string().describe("The Amba project ID."),
5154
5375
  name: z.string().describe("Prompt name to update."),
5155
- provider: PROVIDER_ENUM.describe("AI provider."),
5376
+ provider: PROVIDER_ENUM$1.describe("AI provider."),
5156
5377
  model: z.string().describe("Provider-native model id."),
5157
5378
  system_prompt: z.string().nullable().optional().describe("System prompt; null/omit to clear."),
5158
5379
  max_tokens: z.number().int().min(1).max(2e5).optional().describe("Max output tokens."),
@@ -5232,6 +5453,179 @@ function registerTools$4(server, apiClient) {
5232
5453
  }, ["amba_invoke_ai_prompt"]);
5233
5454
  }
5234
5455
  //#endregion
5456
+ //#region src/tools/ai-providers-admin.ts
5457
+ const PROVIDER_ENUM = z.enum([
5458
+ "anthropic",
5459
+ "openai",
5460
+ "mistral",
5461
+ "gemini"
5462
+ ]);
5463
+ function registerTools$6(server, apiClient) {
5464
+ registerTool(server, apiClient, "amba_ai_providers_set", [
5465
+ "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.*`.",
5466
+ "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.",
5467
+ "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.",
5468
+ "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."
5469
+ ].join(" "), {
5470
+ project_id: z.string().describe("The Amba project ID."),
5471
+ provider: PROVIDER_ENUM.describe("Provider the key belongs to: \"anthropic\", \"openai\", \"mistral\", or \"gemini\"."),
5472
+ api_key: z.string().min(10).describe("The upstream provider API key (e.g. an Anthropic or OpenAI account key).")
5473
+ }, async ({ project_id, provider, api_key }, { pat }) => {
5474
+ return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/ai/providers`, { body: {
5475
+ name: provider,
5476
+ api_key
5477
+ } }));
5478
+ });
5479
+ 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 }) => {
5480
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/ai/providers`));
5481
+ });
5482
+ 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.", {
5483
+ project_id: z.string().describe("The Amba project ID."),
5484
+ provider: PROVIDER_ENUM.describe("Provider to revoke.")
5485
+ }, async ({ project_id, provider }, { pat }) => {
5486
+ return jsonResult(await callWithPat(apiClient, pat, "DELETE", `/projects/${encodeURIComponent(project_id)}/ai/providers/${encodeURIComponent(provider)}`) ?? {
5487
+ name: provider,
5488
+ deleted: true
5489
+ });
5490
+ });
5491
+ }
5492
+ //#endregion
5493
+ //#region src/tools/experiments.ts
5494
+ const variantSchema = z.object({
5495
+ key: z.string().describe("Variant key (e.g. \"control\", \"treatment\"). Unique within the experiment."),
5496
+ weight: z.number().int().min(1).max(1e6).describe("Relative allocation weight (positive integer). Weights need not sum to 100.")
5497
+ });
5498
+ const STATUS_ENUM = z.enum([
5499
+ "active",
5500
+ "paused",
5501
+ "ended"
5502
+ ]).describe("active = assigns + exposes new users; paused = serves existing assignments but issues no new ones; ended = read-only.");
5503
+ function registerTools$5(server, apiClient) {
5504
+ registerTool(server, apiClient, "amba_experiments_create", [
5505
+ "Create an A/B experiment: a stable `key`, a display `name`, and >= 2 weighted variants.",
5506
+ "End-users are bucketed into a STICKY variant the first time they request an assignment",
5507
+ "(via the SDK) using a deterministic hash over the variant weights; the bucket then never",
5508
+ "changes even if you re-weight later. The FIRST variant is the control for significance."
5509
+ ].join(" "), {
5510
+ project_id: z.string().describe("The Amba project ID."),
5511
+ key: z.string().describe("Stable experiment key (the SDK addresses the experiment by this)."),
5512
+ name: z.string().describe("Human-readable experiment name."),
5513
+ description: z.string().nullable().optional().describe("Optional description."),
5514
+ variants: z.array(variantSchema).min(2).describe("At least 2 weighted variants. First is the control."),
5515
+ status: STATUS_ENUM.optional().describe("Initial status. Defaults to \"active\".")
5516
+ }, async ({ project_id, key, name, description, variants, status }, { client }) => {
5517
+ const payload = {
5518
+ key,
5519
+ name,
5520
+ variants
5521
+ };
5522
+ if (description !== void 0) payload.description = description;
5523
+ if (status !== void 0) payload.status = status;
5524
+ const result = await client.post(`/projects/${encodeURIComponent(project_id)}/experiments`, payload);
5525
+ return { content: [{
5526
+ type: "text",
5527
+ text: JSON.stringify(result, null, 2)
5528
+ }] };
5529
+ });
5530
+ 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 }) => {
5531
+ const result = await client.get(`/projects/${encodeURIComponent(project_id)}/experiments`);
5532
+ return { content: [{
5533
+ type: "text",
5534
+ text: JSON.stringify(result, null, 2)
5535
+ }] };
5536
+ });
5537
+ registerTool(server, apiClient, "amba_experiments_get", "Get a single experiment by key (name, variants, status, timestamps). 404 if not found.", {
5538
+ project_id: z.string().describe("The Amba project ID."),
5539
+ key: z.string().describe("Experiment key.")
5540
+ }, async ({ project_id, key }, { client }) => {
5541
+ const result = await client.get(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}`);
5542
+ return { content: [{
5543
+ type: "text",
5544
+ text: JSON.stringify(result, null, 2)
5545
+ }] };
5546
+ });
5547
+ registerTool(server, apiClient, "amba_experiments_update", [
5548
+ "Update an experiment's name, description, variants, and/or status. Only the supplied",
5549
+ "fields change; variants (when supplied) replace the whole list. Re-weighting NEVER",
5550
+ "re-rolls already-assigned users (assignments are sticky); it only affects users assigned",
5551
+ "after the edit. Set status to \"paused\" to stop new assignments or \"ended\" to freeze it."
5552
+ ].join(" "), {
5553
+ project_id: z.string().describe("The Amba project ID."),
5554
+ key: z.string().describe("Experiment key to update."),
5555
+ name: z.string().optional().describe("New name."),
5556
+ description: z.string().nullable().optional().describe("New description; null to clear."),
5557
+ variants: z.array(variantSchema).min(2).optional().describe("Replacement variant list (>= 2). First is the control."),
5558
+ status: STATUS_ENUM.optional().describe("New status.")
5559
+ }, async ({ project_id, key, name, description, variants, status }, { client }) => {
5560
+ const payload = {};
5561
+ if (name !== void 0) payload.name = name;
5562
+ if (description !== void 0) payload.description = description;
5563
+ if (variants !== void 0) payload.variants = variants;
5564
+ if (status !== void 0) payload.status = status;
5565
+ const result = await client.patch(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}`, payload);
5566
+ return { content: [{
5567
+ type: "text",
5568
+ text: JSON.stringify(result, null, 2)
5569
+ }] };
5570
+ });
5571
+ registerTool(server, apiClient, "amba_experiments_delete", "Delete an experiment by key. Cascades its assignments + exposures. Hard delete.", {
5572
+ project_id: z.string().describe("The Amba project ID."),
5573
+ key: z.string().describe("Experiment key to delete.")
5574
+ }, async ({ project_id, key }, { client }) => {
5575
+ const result = await client.delete(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}`);
5576
+ return { content: [{
5577
+ type: "text",
5578
+ text: JSON.stringify(result ?? {
5579
+ key,
5580
+ deleted: true
5581
+ }, null, 2)
5582
+ }] };
5583
+ });
5584
+ registerTool(server, apiClient, "amba_experiments_results", [
5585
+ "Compute per-variant results for an experiment: assigned_count, exposed_count, and",
5586
+ "(when `conversion_event` is supplied) converted_count + conversion_rate — the distinct",
5587
+ "exposed users who emitted that analytics event at/after their assignment. Each non-control",
5588
+ "variant also gets a two-proportion z-test vs the control (first variant): a z_score and an",
5589
+ "approximate two-sided p_value. The control variant's z_score/p_value are null."
5590
+ ].join(" "), {
5591
+ project_id: z.string().describe("The Amba project ID."),
5592
+ key: z.string().describe("Experiment key."),
5593
+ 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).")
5594
+ }, async ({ project_id, key, conversion_event }, { client }) => {
5595
+ const query = {};
5596
+ if (conversion_event !== void 0) query.conversion_event = conversion_event;
5597
+ const result = await client.get(`/projects/${encodeURIComponent(project_id)}/experiments/${encodeURIComponent(key)}/results`, query);
5598
+ return { content: [{
5599
+ type: "text",
5600
+ text: JSON.stringify(result, null, 2)
5601
+ }] };
5602
+ });
5603
+ }
5604
+ //#endregion
5605
+ //#region src/tools/promotion.ts
5606
+ function registerTools$4(server, apiClient) {
5607
+ registerTool(server, apiClient, "amba_project_export", [
5608
+ "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.",
5609
+ "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.",
5610
+ "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."
5611
+ ].join(" "), { project_id: z.string().describe("The Amba project ID to export configuration FROM.") }, async ({ project_id }, { pat }) => {
5612
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/promotion/export`));
5613
+ });
5614
+ registerTool(server, apiClient, "amba_project_import", [
5615
+ "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).",
5616
+ "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.",
5617
+ "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)."
5618
+ ].join(" "), {
5619
+ project_id: z.string().describe("The Amba project ID to import configuration INTO."),
5620
+ bundle: z.record(z.unknown()).describe("The bundle object returned by `amba_project_export` (its `data` field)."),
5621
+ 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.")
5622
+ }, async ({ project_id, bundle, mode }, { pat }) => {
5623
+ const body = { bundle };
5624
+ if (mode !== void 0) body.mode = mode;
5625
+ return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/promotion/import`, { body }));
5626
+ });
5627
+ }
5628
+ //#endregion
5235
5629
  //#region src/tools/billing.ts
5236
5630
  /**
5237
5631
  * Static tier catalog. Mirrors the marketing-page structure shipped in
@@ -6993,7 +7387,7 @@ Admin tools authenticate the developer/agent (pass \`pat\` or send it as the inb
6993
7387
  | \`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
7388
  | \`amba_collections_list\` | List collections in this project. | \`{ project_id }\` |
6995
7389
  | \`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 } }\` |
7390
+ | \`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
7391
  | \`amba_collections_delete\` | Drop the table (destructive). \`confirm\` must equal the collection name. | \`{ project_id, name: "todos", confirm: "todos" }\` |
6998
7392
  | \`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
7393
  | \`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 +7401,7 @@ Admin tools authenticate the developer/agent (pass \`pat\` or send it as the inb
7007
7401
  | \`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
7402
  | \`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
7403
 
7010
- Column types: \`text\`, \`int\`, \`bigint\`, \`float\`, \`boolean\`, \`timestamptz\`, \`date\`, \`json\`, \`jsonb\`, \`uuid\`, \`vector(<dim>)\` (e.g. \`vector(1536)\` for OpenAI embeddings).
7404
+ 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
7405
 
7012
7406
  ### Functions (serverless code)
7013
7407
 
@@ -7027,16 +7421,23 @@ Run user code in a sandbox triggered by HTTP, cron, or webhook. The function get
7027
7421
 
7028
7422
  ### AI prompts
7029
7423
 
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.
7424
+ 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.
7425
+
7426
+ **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.
7427
+
7428
+ > 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
7429
 
7032
7430
  | Tool | Purpose | Example args |
7033
7431
  | --- | --- | --- |
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"] }\` |
7432
+ | \`amba_ai_providers_set\` | Register / rotate the upstream provider API key. **Do this first.** | \`{ project_id, provider: "anthropic", api_key: "sk-ant-..." }\` |
7433
+ | \`amba_ai_providers_list\` | List registered providers (\`configured\` = key set). | \`{ project_id }\` |
7434
+ | \`amba_ai_providers_delete\` | Revoke a provider key (fails if prompts still reference it). | \`{ project_id, provider: "anthropic" }\` |
7435
+ | \`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
7436
  | \`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 }\` |
7437
+ | \`amba_ai_prompts_get\` | Read one prompt. | \`{ project_id, name }\` |
7438
+ | \`amba_ai_prompts_update\` | Edit a prompt (replaces all fields; bumps version). | \`{ project_id, name, provider, model, system_prompt: "..." }\` |
7439
+ | \`amba_ai_prompts_invoke\` | Invoke a prompt server-side (admin testing). \`messages\` is a provider-shaped array. | \`{ project_id, name, messages: [{ role: "user", content: "..." }] }\` |
7440
+ | \`amba_ai_prompts_delete\` | Delete. | \`{ project_id, name }\` |
7040
7441
 
7041
7442
  ### Analytics + events + sessions
7042
7443
 
@@ -7052,9 +7453,11 @@ Managed LLM templates: stored prompt with model + system message + variables, ca
7052
7453
 
7053
7454
  ### Secrets + configs + integrations
7054
7455
 
7456
+ 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).
7457
+
7055
7458
  | Tool | Purpose | Example args |
7056
7459
  | --- | --- | --- |
7057
- | \`amba_secrets_set\` | Set a tenant secret (encrypted at rest). | \`{ project_id, name: "OPENAI_API_KEY", value: "sk-..." }\` |
7460
+ | \`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
7461
  | \`amba_secrets_get\` | Read a secret (returns \`"<redacted>"\` unless explicitly requested). | \`{ project_id, name }\` |
7059
7462
  | \`amba_secrets_list\` | List secret names. | \`{ project_id }\` |
7060
7463
  | \`amba_secrets_delete\` | Delete. | \`{ project_id, name }\` |
@@ -7113,9 +7516,9 @@ const newTodo = await Amba.collections.insert('todos', { title: 'Ship the app',
7113
7516
  await Amba.collections.update('todos', newTodo.id, { done: true });
7114
7517
  await Amba.collections.delete('todos', newTodo.id);
7115
7518
 
7116
- // AI — call a managed prompt
7519
+ // AI — call a managed prompt (prompt_slug names the registered prompt)
7117
7520
  const response = await Amba.ai.anthropic.messages.create({
7118
- prompt_key: 'summarize',
7521
+ prompt_slug: 'summarize',
7119
7522
  variables: { text: 'A long article about backend services …' },
7120
7523
  });
7121
7524
 
@@ -7183,8 +7586,7 @@ let showBeta = try await Amba.flags.get(name: "beta_feature")
7183
7586
  try await Amba.events.track("app_opened", properties: ["source": "deep_link"])
7184
7587
 
7185
7588
  let reply = try await Amba.ai.anthropic.messages.create(
7186
- promptKey: "summarize",
7187
- variables: ["text": "A long article..."]
7589
+ request: AiMessageRequest(promptSlug: "summarize", variables: ["text": "A long article..."])
7188
7590
  )
7189
7591
  \`\`\`
7190
7592
 
@@ -7241,7 +7643,7 @@ Batch.
7241
7643
  - [ ] Resend (transactional email)
7242
7644
  - [ ] Stripe (web payments / subscriptions)
7243
7645
  - [ ] Mixpanel / PostHog / Segment (analytics forwarding)
7244
- - [ ] OpenAI / Anthropic (LLM keys — required for \`Amba.ai.*\` calls)
7646
+ - [ ] OpenAI / Anthropic / Mistral / Gemini LLM keys (required for \`Amba.ai.*\` — set via \`amba_ai_providers_set\`, **not** \`amba_integrations_configure\`)
7245
7647
 
7246
7648
  6. **Feature flags:** seed any starter flags?
7247
7649
  - Yes — wire \`beta_feature\` (off by default) so I can ship the wiring before the feature exists
@@ -7256,7 +7658,7 @@ Batch.
7256
7658
  1. Before creating:
7257
7659
  - \`amba_collections_list\` — match on \`name\`. Collisions: never silently recreate (data loss). Offer \`amba_collections_alter\` to add new columns instead.
7258
7660
  - \`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.
7661
+ - \`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
7662
  - \`amba_integrations_list\` — match on \`provider\`. Same.
7261
7663
  - \`amba_configs_list\` — match on \`key\`. Same.
7262
7664
 
@@ -7438,6 +7840,8 @@ const TOOL_CATEGORY = {
7438
7840
  amba_developer_rotate_pat: "identity",
7439
7841
  amba_users_list: "identity",
7440
7842
  amba_list_users: "identity",
7843
+ amba_users_create: "identity",
7844
+ amba_create_user: "identity",
7441
7845
  amba_users_get: "identity",
7442
7846
  amba_users_delete: "identity",
7443
7847
  amba_users_export: "identity",
@@ -7486,6 +7890,7 @@ const TOOL_CATEGORY = {
7486
7890
  amba_onboarding_get_stats: "engagement",
7487
7891
  amba_get_onboarding_stats: "engagement",
7488
7892
  amba_content_libraries_create: "engagement",
7893
+ amba_content_libraries_delete: "engagement",
7489
7894
  amba_create_content_library: "engagement",
7490
7895
  amba_content_list_libraries: "engagement",
7491
7896
  amba_content_items_add: "engagement",
@@ -7555,6 +7960,14 @@ const TOOL_CATEGORY = {
7555
7960
  amba_update_leaderboard: "gamification",
7556
7961
  amba_leaderboards_delete: "gamification",
7557
7962
  amba_delete_leaderboard: "gamification",
7963
+ amba_leagues_create: "gamification",
7964
+ amba_create_league: "gamification",
7965
+ amba_leagues_list: "gamification",
7966
+ amba_list_leagues: "gamification",
7967
+ amba_leagues_update: "gamification",
7968
+ amba_update_league: "gamification",
7969
+ amba_leagues_list_cohorts: "gamification",
7970
+ amba_list_league_cohorts: "gamification",
7558
7971
  amba_challenges_create: "gamification",
7559
7972
  amba_create_challenge: "gamification",
7560
7973
  amba_challenges_list: "gamification",
@@ -7683,6 +8096,14 @@ const TOOL_CATEGORY = {
7683
8096
  amba_funnels_update: "analytics",
7684
8097
  amba_funnels_delete: "analytics",
7685
8098
  amba_funnels_query: "analytics",
8099
+ amba_experiments_create: "analytics",
8100
+ amba_experiments_list: "analytics",
8101
+ amba_experiments_get: "analytics",
8102
+ amba_experiments_update: "analytics",
8103
+ amba_experiments_delete: "analytics",
8104
+ amba_experiments_results: "analytics",
8105
+ amba_project_export: "infrastructure",
8106
+ amba_project_import: "infrastructure",
7686
8107
  amba_projects_create: "infrastructure",
7687
8108
  amba_create_project: "infrastructure",
7688
8109
  amba_projects_list: "infrastructure",
@@ -7709,6 +8130,7 @@ const TOOL_CATEGORY = {
7709
8130
  amba_delete_collection: "infrastructure",
7710
8131
  amba_admin_insert_row: "infrastructure",
7711
8132
  amba_admin_list_rows: "infrastructure",
8133
+ amba_admin_aggregate_rows: "infrastructure",
7712
8134
  amba_client_insert_row: "infrastructure",
7713
8135
  amba_client_list_rows: "infrastructure",
7714
8136
  amba_client_get_row: "infrastructure",
@@ -7784,6 +8206,9 @@ const TOOL_CATEGORY = {
7784
8206
  amba_integrations_patch: "infrastructure",
7785
8207
  amba_integrations_test: "infrastructure",
7786
8208
  amba_test_integration: "infrastructure",
8209
+ amba_ai_providers_set: "infrastructure",
8210
+ amba_ai_providers_list: "infrastructure",
8211
+ amba_ai_providers_delete: "infrastructure",
7787
8212
  amba_ai_prompts_create: "infrastructure",
7788
8213
  amba_create_ai_prompt: "infrastructure",
7789
8214
  amba_ai_prompts_list: "infrastructure",
@@ -7850,7 +8275,11 @@ function getToolCategory(toolName) {
7850
8275
  * called against it once.
7851
8276
  */
7852
8277
  function registerAllTools(server, apiClient) {
7853
- registerTools$10(server, apiClient);
8278
+ registerTools$13(server, apiClient);
8279
+ registerTools$33(server, apiClient);
8280
+ registerTools$32(server, apiClient);
8281
+ registerTools$31(server, apiClient);
8282
+ registerTools$30(server, apiClient);
7854
8283
  registerTools$29(server, apiClient);
7855
8284
  registerTools$28(server, apiClient);
7856
8285
  registerTools$27(server, apiClient);
@@ -7867,9 +8296,9 @@ function registerAllTools(server, apiClient) {
7867
8296
  registerTools$16(server, apiClient);
7868
8297
  registerTools$15(server, apiClient);
7869
8298
  registerTools$14(server, apiClient);
7870
- registerTools$13(server, apiClient);
7871
8299
  registerTools$12(server, apiClient);
7872
8300
  registerTools$11(server, apiClient);
8301
+ registerTools$10(server, apiClient);
7873
8302
  registerTools$9(server, apiClient);
7874
8303
  registerTools$8(server, apiClient);
7875
8304
  registerTools$7(server, apiClient);