@layers/amba-mcp 4.0.2 → 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() {
@@ -286,6 +286,236 @@ function warnDeprecatedAlias(alias, canonical) {
286
286
  console.warn(`[amba-mcp] tool "${alias}" is deprecated; use "${canonical}" instead. The alias still works but will be removed in a future release.`);
287
287
  }
288
288
  //#endregion
289
+ //#region src/lib/annotations.ts
290
+ /**
291
+ * Read-only verbs. A tool whose name contains one of these (and no
292
+ * destructive verb) does not mutate project state — it reads/derives/
293
+ * exports data. Maps to `readOnlyHint: true`.
294
+ */
295
+ const READ_VERBS = new Set([
296
+ "get",
297
+ "list",
298
+ "find",
299
+ "describe",
300
+ "stats",
301
+ "export",
302
+ "analytics",
303
+ "me",
304
+ "status",
305
+ "count",
306
+ "search",
307
+ "check",
308
+ "tiers",
309
+ "verify",
310
+ "logs",
311
+ "query",
312
+ "aggregate",
313
+ "results"
314
+ ]);
315
+ /**
316
+ * Destructive verbs. A tool whose name contains one of these MAY perform
317
+ * a destructive update (irreversible removal of existing state). Maps to
318
+ * `destructiveHint: true`. `reset` is included because `*_reset_sandbox`
319
+ * wipes end-user data.
320
+ */
321
+ const DESTRUCTIVE_VERBS = new Set([
322
+ "delete",
323
+ "remove",
324
+ "revoke",
325
+ "drop",
326
+ "reset"
327
+ ]);
328
+ /**
329
+ * Write verbs — mutations that are additive / non-destructive (create a
330
+ * row, update fields, grant currency, deploy a function, send a push, …).
331
+ * Maps to `destructiveHint: false`. This set exists so a write tool that
332
+ * matches NONE of the read/destructive verbs is still positively
333
+ * classified (rather than falling through unannotated). Kept explicit so
334
+ * an unrecognised verb surfaces as a test failure instead of silently
335
+ * defaulting.
336
+ */
337
+ const WRITE_VERBS = new Set([
338
+ "create",
339
+ "update",
340
+ "set",
341
+ "patch",
342
+ "grant",
343
+ "spend",
344
+ "deduct",
345
+ "deploy",
346
+ "send",
347
+ "add",
348
+ "configure",
349
+ "invoke",
350
+ "schedule",
351
+ "assign",
352
+ "upload",
353
+ "rotate",
354
+ "reprovision",
355
+ "transfer",
356
+ "pause",
357
+ "resume",
358
+ "trigger",
359
+ "evaluate",
360
+ "test",
361
+ "approve",
362
+ "reject",
363
+ "escalate",
364
+ "replay",
365
+ "import",
366
+ "alter",
367
+ "insert",
368
+ "invite",
369
+ "register",
370
+ "purchase",
371
+ "signup",
372
+ "login",
373
+ "refresh"
374
+ ]);
375
+ /**
376
+ * Split a tool name into lowercase verb-candidate tokens, dropping the
377
+ * `amba_` prefix. Exported for the test suite.
378
+ */
379
+ function toolNameTokens(name) {
380
+ return name.replace(/^amba_/, "").split("_").filter(Boolean);
381
+ }
382
+ /**
383
+ * Classify a tool name into its behavioural class by PRECEDENCE
384
+ * (destructive > read > write). Returns `null` if no token matches any
385
+ * known verb — that should never happen for a real Amba tool and the
386
+ * test suite asserts it doesn't, so a `null` at runtime means a new tool
387
+ * used an unrecognised verb and needs a verb-set entry here.
388
+ */
389
+ function classifyToolVerb(name) {
390
+ const tokens = toolNameTokens(name);
391
+ let sawRead = false;
392
+ let sawWrite = false;
393
+ for (const token of tokens) {
394
+ if (DESTRUCTIVE_VERBS.has(token)) return "destructive";
395
+ if (READ_VERBS.has(token)) sawRead = true;
396
+ else if (WRITE_VERBS.has(token)) sawWrite = true;
397
+ }
398
+ if (sawRead) return "read";
399
+ if (sawWrite) return "write";
400
+ return null;
401
+ }
402
+ /** Title-case a single token (`currencies` → `Currencies`). */
403
+ function capitalize(token) {
404
+ return token.length === 0 ? token : token[0].toUpperCase() + token.slice(1);
405
+ }
406
+ /**
407
+ * Verb-set tokens that read as nouns/objects far more often than as the
408
+ * operation verb, so they must NOT be chosen as the title's leading verb.
409
+ * They stay in the verb sets above for CLASSIFICATION (e.g. `*_status`,
410
+ * `*_stats` are read-only), but for TITLE purposes they're objects:
411
+ *
412
+ * amba_get_friendship_stats → "Get Friendship Stats" (not "Stats …")
413
+ * amba_pause_function_schedule→ "Pause Function Schedule" (not "Schedule …")
414
+ * amba_functions_get_logs → "Get Functions Logs"
415
+ * amba_billing_status → "Billing Status" (no action verb at all)
416
+ * amba_sessions_analytics → "Sessions Analytics"
417
+ */
418
+ const NOUN_LIKE_VERBS = new Set([
419
+ "stats",
420
+ "status",
421
+ "logs",
422
+ "tiers",
423
+ "analytics",
424
+ "schedule",
425
+ "me"
426
+ ]);
427
+ /**
428
+ * The verbs eligible to be a title's leading word: every classification
429
+ * verb that isn't a noun-like token. These are unambiguous action verbs
430
+ * (`create`, `get`, `delete`, `set`, `pause`, …).
431
+ */
432
+ const STRONG_TITLE_VERBS = new Set([
433
+ ...READ_VERBS,
434
+ ...WRITE_VERBS,
435
+ ...DESTRUCTIVE_VERBS
436
+ ].filter((v) => !NOUN_LIKE_VERBS.has(v)));
437
+ /**
438
+ * Pick the index of the OPERATIVE action verb for the title, or -1 if the
439
+ * name has no action verb (e.g. `billing_status`, `sessions_analytics`).
440
+ *
441
+ * A naive `tokens.find()` is wrong two different ways:
442
+ * - `amba_currency_grant_rules_create` has `grant` (a noun-modifier in
443
+ * `grant_rules`) AND `create`; `find` picks `grant` → "Grant … Create".
444
+ * - `amba_get_friendship_stats` has `get` AND `stats`; preferring the
445
+ * trailing match picks `stats` → "Stats Get Friendship".
446
+ *
447
+ * Amba's naming puts the operation verb at a token-list BOUNDARY: leading
448
+ * for legacy `amba_<verb>_<resource>`, trailing/mid for canonical
449
+ * `amba_<resource>_<verb>[_<object>]`. So:
450
+ * 1. If the FIRST token is a strong verb → it's the operative verb
451
+ * (legacy alias: `get_friendship_stats`, `create_currency`).
452
+ * 2. Else take the LAST strong verb — skips trailing object nouns like
453
+ * `stats`/`schedule` (excluded from `STRONG_TITLE_VERBS`) and skips
454
+ * mid-name noun-modifiers like `grant` in `grant_rules_create`
455
+ * (`get_stats`/`set_price`/`grant_rules_create` all resolve right).
456
+ * 3. No strong verb → -1 (caller title-cases in order).
457
+ */
458
+ function operativeVerbIndex(tokens) {
459
+ if (tokens.length > 0 && STRONG_TITLE_VERBS.has(tokens[0])) return 0;
460
+ let lastStrong = -1;
461
+ for (let i = 0; i < tokens.length; i += 1) if (STRONG_TITLE_VERBS.has(tokens[i])) lastStrong = i;
462
+ return lastStrong;
463
+ }
464
+ /**
465
+ * Derive a human-readable Title-Case title from the tool name. The
466
+ * operative action verb (see `operativeVerbIndex`) is moved to the front
467
+ * so the title reads as an imperative; remaining tokens keep their order:
468
+ *
469
+ * amba_currencies_create → "Create Currencies"
470
+ * amba_content_delete_schedule → "Delete Content Schedule"
471
+ * amba_currency_grant_rules_create → "Create Currency Grant Rules"
472
+ * amba_get_friendship_stats → "Get Friendship Stats"
473
+ * amba_pause_function_schedule → "Pause Function Schedule"
474
+ * amba_catalog_items_set_price → "Set Catalog Items Price"
475
+ * amba_users_get → "Get Users"
476
+ *
477
+ * When there is no action verb (`billing_status`, `sessions_analytics`)
478
+ * the tokens are title-cased in order — a readable noun-phrase fallback.
479
+ */
480
+ function deriveToolTitle(name) {
481
+ const tokens = toolNameTokens(name);
482
+ const idx = operativeVerbIndex(tokens);
483
+ if (idx >= 0) {
484
+ const rest = tokens.filter((_, i) => i !== idx);
485
+ return [tokens[idx], ...rest].map(capitalize).join(" ");
486
+ }
487
+ return tokens.map(capitalize).join(" ");
488
+ }
489
+ /**
490
+ * Derive the full `ToolAnnotations` (title + read-only/destructive hints)
491
+ * for a tool name. This is the single function `with-pat.ts` calls for
492
+ * every tool it registers, so annotations stay uniform and automatic.
493
+ */
494
+ function deriveToolAnnotations(name) {
495
+ const title = deriveToolTitle(name);
496
+ switch (classifyToolVerb(name)) {
497
+ case "read": return {
498
+ title,
499
+ readOnlyHint: true
500
+ };
501
+ case "destructive": return {
502
+ title,
503
+ readOnlyHint: false,
504
+ destructiveHint: true
505
+ };
506
+ case "write": return {
507
+ title,
508
+ readOnlyHint: false,
509
+ destructiveHint: false
510
+ };
511
+ default: return {
512
+ title,
513
+ readOnlyHint: false,
514
+ destructiveHint: true
515
+ };
516
+ }
517
+ }
518
+ //#endregion
289
519
  //#region src/lib/with-pat.ts
290
520
  /**
291
521
  * The `pat` schema entry injected into every tool that goes through
@@ -362,8 +592,8 @@ function registerTool(server, apiClient, name, description, schema, handler, ali
362
592
  client
363
593
  });
364
594
  };
365
- server.tool(name, description, extendedSchema, wrappedHandler(name));
366
- for (const alias of aliases) server.tool(alias, description, extendedSchema, (async (rawArgs) => {
595
+ server.tool(name, description, extendedSchema, deriveToolAnnotations(name), wrappedHandler(name));
596
+ for (const alias of aliases) server.tool(alias, description, extendedSchema, deriveToolAnnotations(alias), (async (rawArgs) => {
367
597
  warnDeprecatedAlias(alias, name);
368
598
  return wrappedHandler(name)(rawArgs);
369
599
  }));
@@ -375,15 +605,15 @@ function registerTool(server, apiClient, name, description, schema, handler, ali
375
605
  * should go through `registerTool`.
376
606
  */
377
607
  function registerPublicTool(server, name, description, schema, handler, aliases = []) {
378
- server.tool(name, description, schema, handler);
379
- for (const alias of aliases) server.tool(alias, description, schema, (async (args) => {
608
+ server.tool(name, description, schema, deriveToolAnnotations(name), handler);
609
+ for (const alias of aliases) server.tool(alias, description, schema, deriveToolAnnotations(alias), (async (args) => {
380
610
  warnDeprecatedAlias(alias, name);
381
611
  return handler(args);
382
612
  }));
383
613
  }
384
614
  //#endregion
385
615
  //#region src/tools/projects.ts
386
- function registerTools$25(server, apiClient) {
616
+ function registerTools$33(server, apiClient) {
387
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 }) => {
388
618
  const result = await client.get("/projects");
389
619
  return { content: [{
@@ -490,7 +720,7 @@ function registerTools$25(server, apiClient) {
490
720
  }
491
721
  //#endregion
492
722
  //#region src/tools/push.ts
493
- function registerTools$24(server, apiClient) {
723
+ function registerTools$32(server, apiClient) {
494
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.", {
495
725
  project_id: z.string().describe("The project ID"),
496
726
  title: z.string().describe("Push notification title shown to the user"),
@@ -620,7 +850,7 @@ const segmentRulesSchema = z.object({
620
850
  operator: z.enum(["AND", "OR"]).describe("Logical operator combining conditions"),
621
851
  conditions: z.array(segmentConditionSchema).describe("Array of filter conditions")
622
852
  });
623
- function registerTools$23(server, apiClient) {
853
+ function registerTools$31(server, apiClient) {
624
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\").", {
625
855
  project_id: z.string().describe("The project ID"),
626
856
  include_system: z.boolean().optional().describe("Include built-in system segments in the result. Defaults to false.")
@@ -705,8 +935,8 @@ const configConditionSchema = z.object({
705
935
  percentage: z.number().optional().describe("Percentage rollout (0-100)"),
706
936
  value: z.unknown().describe("Override value for this condition")
707
937
  });
708
- function registerTools$22(server, apiClient) {
709
- registerTool(server, apiClient, "amba_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
+ function registerTools$30(server, apiClient) {
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).", {
710
940
  project_id: z.string().describe("The project ID"),
711
941
  include_system: z.boolean().optional().describe("Include platform-provided default config keys in the result. Defaults to false.")
712
942
  }, async ({ project_id, include_system }, { client }) => {
@@ -717,8 +947,8 @@ function registerTools$22(server, apiClient) {
717
947
  type: "text",
718
948
  text: JSON.stringify(result, null, 2)
719
949
  }] };
720
- }, ["amba_list_configs"]);
721
- registerTool(server, apiClient, "amba_configs_create", "Create or upsert a remote-config key. Supports string, number, boolean, and JSON value types. Can include segment-based or percentage-based conditions for targeted rollouts. The handler is upsert-on-key — calling this for an existing key replaces it.", {
950
+ }, ["amba_configs_list", "amba_list_configs"]);
951
+ registerTool(server, apiClient, "amba_remote_configs_create", "Create or upsert a remote-config key. Supports string, number, boolean, and JSON value types. Can include segment-based or percentage-based conditions for targeted rollouts. The handler is upsert-on-key — calling this for an existing key replaces it.", {
722
952
  project_id: z.string().describe("The project ID"),
723
953
  key: z.string().describe("Config key name (e.g. \"feature_paywall_v2\", \"daily_limit\")"),
724
954
  value: z.unknown().describe("The default config value"),
@@ -743,8 +973,8 @@ function registerTools$22(server, apiClient) {
743
973
  type: "text",
744
974
  text: JSON.stringify(result, null, 2)
745
975
  }] };
746
- }, ["amba_create_config"]);
747
- registerTool(server, apiClient, "amba_configs_update", "Partially update a single remote-config key. Only fields provided are touched — other fields are preserved. Updating `value` bumps the per-row version counter; any successful mutation also recomputes the singleton config_versions hash so cached clients see the change.", {
976
+ }, ["amba_configs_create", "amba_create_config"]);
977
+ registerTool(server, apiClient, "amba_remote_configs_update", "Partially update a single remote-config key. Only fields provided are touched — other fields are preserved. Updating `value` bumps the per-row version counter; any successful mutation also recomputes the singleton config_versions hash so cached clients see the change.", {
748
978
  project_id: z.string().describe("The project ID"),
749
979
  key: z.string().describe("The config key to update"),
750
980
  value: z.unknown().optional().describe("New default value (omit to leave unchanged)"),
@@ -760,8 +990,8 @@ function registerTools$22(server, apiClient) {
760
990
  type: "text",
761
991
  text: JSON.stringify(result, null, 2)
762
992
  }] };
763
- }, ["amba_update_config"]);
764
- registerTool(server, apiClient, "amba_configs_delete", "Delete a remote-config key. Idempotent — deleting a non-existent key returns success. Recomputes the config_versions hash so clients refresh cached config on next fetch.", {
993
+ }, ["amba_configs_update", "amba_update_config"]);
994
+ registerTool(server, apiClient, "amba_remote_configs_delete", "Delete a remote-config key. Idempotent — deleting a non-existent key returns success. Recomputes the config_versions hash so clients refresh cached config on next fetch.", {
765
995
  project_id: z.string().describe("The project ID"),
766
996
  key: z.string().describe("The config key to delete")
767
997
  }, async ({ project_id, key }, { client }) => {
@@ -770,7 +1000,7 @@ function registerTools$22(server, apiClient) {
770
1000
  type: "text",
771
1001
  text: JSON.stringify(result, null, 2)
772
1002
  }] };
773
- }, ["amba_delete_config"]);
1003
+ }, ["amba_configs_delete", "amba_delete_config"]);
774
1004
  }
775
1005
  //#endregion
776
1006
  //#region src/tools/content.ts
@@ -783,8 +1013,8 @@ const contentItemSchema = z.object({
783
1013
  metadata: z.record(z.unknown()).optional().describe("Arbitrary metadata key-value pairs"),
784
1014
  is_premium: z.boolean().optional().describe("Whether this content requires a premium entitlement")
785
1015
  });
786
- function registerTools$21(server, apiClient) {
787
- 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`.", {
788
1018
  project_id: z.string().describe("The project ID"),
789
1019
  name: z.string().describe("Library name (e.g. \"Daily Motivation\", \"Workout Tips\")"),
790
1020
  description: z.string().optional().describe("Description of the library content"),
@@ -799,6 +1029,17 @@ function registerTools$21(server, apiClient) {
799
1029
  text: JSON.stringify(result, null, 2)
800
1030
  }] };
801
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
+ });
802
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.", {
803
1044
  project_id: z.string().describe("The project ID"),
804
1045
  library_id: z.string().describe("The content library ID"),
@@ -842,7 +1083,7 @@ function registerTools$21(server, apiClient) {
842
1083
  text: JSON.stringify(result, null, 2)
843
1084
  }] };
844
1085
  }, ["amba_create_content_schedule"]);
845
- 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 }) => {
846
1087
  const result = await client.get(`/projects/${project_id}/content/libraries`);
847
1088
  return { content: [{
848
1089
  type: "text",
@@ -947,7 +1188,7 @@ function registerTools$21(server, apiClient) {
947
1188
  }
948
1189
  //#endregion
949
1190
  //#region src/tools/streaks.ts
950
- function registerTools$20(server, apiClient) {
1191
+ function registerTools$28(server, apiClient) {
951
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.", {
952
1193
  project_id: z.string().describe("The project ID"),
953
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`."),
@@ -1021,15 +1262,29 @@ function registerTools$20(server, apiClient) {
1021
1262
  text: JSON.stringify(result, null, 2)
1022
1263
  }] };
1023
1264
  });
1265
+ registerTool(server, apiClient, "amba_streaks_reset", "Reset or correct a specific user's streak — for support fixing a wrongly-broken streak (e.g. after an outage). Sets the user's current count and marks the streak active again. Omit set_count to zero the streak (clean restart); pass set_count to restore it to a specific value (longest_count is bumped if exceeded). Returns 404 if the user has no streak row for this definition yet.", {
1266
+ project_id: z.string().describe("The project ID"),
1267
+ streak_id: z.string().describe("The streak definition ID"),
1268
+ app_user_id: z.string().describe("The user whose streak to reset"),
1269
+ set_count: z.number().int().nonnegative().optional().describe("Restore the streak to this count. Omit to zero the streak (clean restart).")
1270
+ }, async ({ project_id, streak_id, app_user_id, set_count }, { client }) => {
1271
+ const payload = { app_user_id };
1272
+ if (set_count !== void 0) payload.set_count = set_count;
1273
+ const result = await client.post(`/projects/${project_id}/streaks/${streak_id}/reset`, payload);
1274
+ return { content: [{
1275
+ type: "text",
1276
+ text: JSON.stringify(result, null, 2)
1277
+ }] };
1278
+ });
1024
1279
  }
1025
1280
  //#endregion
1026
1281
  //#region src/tools/integrations.ts
1027
1282
  const providerEnum = z.enum(INTEGRATION_PROVIDERS);
1028
- function registerTools$19(server, apiClient) {
1029
- 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.", {
1030
1285
  project_id: z.string().describe("The project ID"),
1031
1286
  provider: providerEnum.describe("Integration provider name"),
1032
- 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())
1033
1288
  }, async ({ project_id, provider, config }, { client }) => {
1034
1289
  const result = await client.post(`/projects/${project_id}/integrations`, {
1035
1290
  provider,
@@ -1095,7 +1350,7 @@ const PERIOD_DAYS = {
1095
1350
  "30d": "30",
1096
1351
  "90d": "90"
1097
1352
  };
1098
- function registerTools$18(server, apiClient) {
1353
+ function registerTools$26(server, apiClient) {
1099
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.", {
1100
1355
  project_id: z.string().describe("The project ID"),
1101
1356
  period: z.enum([
@@ -1118,7 +1373,7 @@ function registerTools$18(server, apiClient) {
1118
1373
  }
1119
1374
  //#endregion
1120
1375
  //#region src/tools/users.ts
1121
- function registerTools$17(server, apiClient) {
1376
+ function registerTools$25(server, apiClient) {
1122
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.", {
1123
1378
  project_id: z.string().describe("The project ID"),
1124
1379
  limit: z.number().optional().describe("Maximum number of users to return (default 50)"),
@@ -1137,6 +1392,30 @@ function registerTools$17(server, apiClient) {
1137
1392
  text: JSON.stringify(result, null, 2)
1138
1393
  }] };
1139
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"]);
1140
1419
  registerTool(server, apiClient, "amba_users_get", "Get a single app user by id, with their streaks and entitlements joined in.", {
1141
1420
  project_id: z.string().describe("The project ID"),
1142
1421
  user_id: z.string().describe("The app user ID")
@@ -1396,12 +1675,18 @@ println("Current streak: \${streak.currentCount}")`,
1396
1675
  // For Expo apps, use @layers/amba-expo instead.
1397
1676
  import { Amba } from '@layers/amba-react-native';
1398
1677
 
1399
- // 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.
1400
1682
  await Amba.configure({
1401
1683
  projectId: 'YOUR_PROJECT_ID',
1402
- apiKey: 'YOUR_CLIENT_API_KEY',
1684
+ clientKey: 'YOUR_CLIENT_API_KEY',
1403
1685
  });
1404
1686
 
1687
+ // Optional — establish the session up front instead of lazily:
1688
+ // await Amba.auth.signInAnonymously();
1689
+
1405
1690
  // Track events
1406
1691
  await Amba.events.track('workout_completed', {
1407
1692
  duration_minutes: 30,
@@ -1423,9 +1708,11 @@ import { Amba } from '@layers/amba-expo';
1423
1708
 
1424
1709
  export default function RootLayout() {
1425
1710
  useEffect(() => {
1426
- 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({
1427
1714
  projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
1428
- apiKey: 'YOUR_CLIENT_API_KEY',
1715
+ clientKey: 'YOUR_CLIENT_API_KEY',
1429
1716
  });
1430
1717
  }, []);
1431
1718
 
@@ -1491,7 +1778,7 @@ final dailyLimit = await Amba.config.get<int>('daily_limit', defaultValue: 10);
1491
1778
  final streak = await Amba.streaks.get('daily_login');
1492
1779
  print('Current streak: \${streak.currentCount}');`
1493
1780
  };
1494
- function registerTools$16(server, apiClient) {
1781
+ function registerTools$24(server, apiClient) {
1495
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([
1496
1783
  "ios",
1497
1784
  "android",
@@ -1547,7 +1834,7 @@ function registerTools$16(server, apiClient) {
1547
1834
  "1. Replace YOUR_CLIENT_API_KEY with your project's client API key (find it with amba_projects_get)",
1548
1835
  "2. Configure push notification integration (APNs for iOS, FCM for Android) using amba_integrations_configure",
1549
1836
  "3. Create segments to target user groups with amba_segments_create",
1550
- "4. Set up remote config values with amba_configs_create",
1837
+ "4. Set up remote config values with amba_remote_configs_create",
1551
1838
  "5. Create streaks to track user engagement with amba_streaks_create",
1552
1839
  "",
1553
1840
  "## Content delivery loop (worked example — DX-12)",
@@ -1576,7 +1863,7 @@ const criteriaSchema = z.object({
1576
1863
  property_key: z.string().optional().describe("User property key (required for property_value type)"),
1577
1864
  target_value: z.number().describe("Target value to unlock the achievement")
1578
1865
  });
1579
- function registerTools$15(server, apiClient) {
1866
+ function registerTools$23(server, apiClient) {
1580
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.", {
1581
1868
  project_id: z.string().describe("The project ID"),
1582
1869
  key: z.string().describe("Unique key for this achievement (e.g. \"first_workout\", \"streak_master_7\")"),
@@ -1656,10 +1943,32 @@ function registerTools$15(server, apiClient) {
1656
1943
  text: JSON.stringify(result, null, 2)
1657
1944
  }] };
1658
1945
  }, ["amba_delete_achievement"]);
1946
+ registerTool(server, apiClient, "amba_achievements_grant", "Manually award an achievement to a specific user (marks it unlocked now). For support, promos, or corrections — the rest of the achievement surface only defines criteria the system evaluates automatically. Idempotent: re-granting an already-unlocked achievement keeps the original unlock time. Note: this does NOT auto-award the achievement's xp_reward; award XP separately with amba_xp_grant if intended.", {
1947
+ project_id: z.string().describe("The project ID"),
1948
+ achievement_id: z.string().describe("The achievement definition ID"),
1949
+ app_user_id: z.string().describe("The user to award the achievement to")
1950
+ }, async ({ project_id, achievement_id, app_user_id }, { client }) => {
1951
+ const result = await client.post(`/projects/${project_id}/achievements/${achievement_id}/grant`, { app_user_id });
1952
+ return { content: [{
1953
+ type: "text",
1954
+ text: JSON.stringify(result, null, 2)
1955
+ }] };
1956
+ });
1957
+ registerTool(server, apiClient, "amba_achievements_revoke", "Claw back an achievement from a specific user — the inverse of grant. Removes the user's unlock entirely. Returns 404 if the user does not currently hold the achievement.", {
1958
+ project_id: z.string().describe("The project ID"),
1959
+ achievement_id: z.string().describe("The achievement definition ID"),
1960
+ app_user_id: z.string().describe("The user to revoke the achievement from")
1961
+ }, async ({ project_id, achievement_id, app_user_id }, { client }) => {
1962
+ const result = await client.post(`/projects/${project_id}/achievements/${achievement_id}/revoke`, { app_user_id });
1963
+ return { content: [{
1964
+ type: "text",
1965
+ text: JSON.stringify(result, null, 2)
1966
+ }] };
1967
+ });
1659
1968
  }
1660
1969
  //#endregion
1661
1970
  //#region src/tools/challenges.ts
1662
- function registerTools$14(server, apiClient) {
1971
+ function registerTools$22(server, apiClient) {
1663
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.", {
1664
1973
  project_id: z.string().describe("The project ID"),
1665
1974
  name: z.string().describe("Challenge name (e.g. \"7-Day Fitness Sprint\", \"XP Weekend Blitz\")"),
@@ -1770,7 +2079,7 @@ function registerTools$14(server, apiClient) {
1770
2079
  }
1771
2080
  //#endregion
1772
2081
  //#region src/tools/economy.ts
1773
- function registerTools$13(server, apiClient) {
2082
+ function registerTools$21(server, apiClient) {
1774
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.", {
1775
2084
  project_id: z.string().describe("The project ID"),
1776
2085
  code: z.string().describe("Unique currency code (e.g. \"gold\", \"gems\", \"hearts\")"),
@@ -1805,25 +2114,48 @@ function registerTools$13(server, apiClient) {
1805
2114
  text: JSON.stringify(result, null, 2)
1806
2115
  }] };
1807
2116
  }, ["amba_list_currencies"]);
1808
- 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.", {
1809
2118
  project_id: z.string().describe("The project ID"),
1810
2119
  app_user_id: z.string().describe("The user to grant currency to"),
1811
2120
  currency_code: z.string().describe("Currency code (e.g. \"gold\")"),
1812
2121
  amount: z.number().describe("Amount to grant (positive integer)"),
1813
- reason: z.string().optional().describe("Reason for the grant (for audit trail)")
1814
- }, 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 }) => {
1815
2125
  const payload = {
1816
2126
  app_user_id,
1817
2127
  currency_code,
1818
2128
  amount
1819
2129
  };
1820
2130
  if (reason !== void 0) payload.reason = reason;
2131
+ if (idempotency_key !== void 0) payload.idempotency_key = idempotency_key;
1821
2132
  const result = await client.post(`/projects/${project_id}/currencies/grant`, payload);
1822
2133
  return { content: [{
1823
2134
  type: "text",
1824
2135
  text: JSON.stringify(result, null, 2)
1825
2136
  }] };
1826
2137
  }, ["amba_grant_currency"]);
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.", {
2139
+ project_id: z.string().describe("The project ID"),
2140
+ app_user_id: z.string().describe("The user to debit currency from"),
2141
+ currency_code: z.string().describe("Currency code (e.g. \"gold\")"),
2142
+ amount: z.number().describe("Positive amount to debit. The endpoint records this as a negative delta on the ledger."),
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 }) => {
2146
+ const payload = {
2147
+ app_user_id,
2148
+ currency_code,
2149
+ amount
2150
+ };
2151
+ if (reason !== void 0) payload.reason = reason;
2152
+ if (idempotency_key !== void 0) payload.idempotency_key = idempotency_key;
2153
+ const result = await client.post(`/projects/${project_id}/currencies/spend`, payload);
2154
+ return { content: [{
2155
+ type: "text",
2156
+ text: JSON.stringify(result, null, 2)
2157
+ }] };
2158
+ });
1827
2159
  registerTool(server, apiClient, "amba_currency_grant_rules_create", "Create a rule that automatically grants virtual currency when a specific event is tracked. Supports flat-amount grants and property-scaled grants (e.g. points per minute). Optional daily cap prevents farming.", {
1828
2160
  project_id: z.string().describe("The project ID"),
1829
2161
  currency_key: z.string().describe("Currency code to grant (e.g. \"calm_points\")"),
@@ -1978,6 +2310,23 @@ function registerTools$13(server, apiClient) {
1978
2310
  text: JSON.stringify(result, null, 2)
1979
2311
  }] };
1980
2312
  }, ["amba_grant_item"]);
2313
+ registerTool(server, apiClient, "amba_inventory_revoke_item", "Remove a catalog item from a user's inventory — the inverse of grant. Decrements the held quantity; returns 400 INSUFFICIENT_QUANTITY if you try to revoke more than the user holds, and 404 if they don't hold the item at all. Revoking down to zero removes the holding entirely.", {
2314
+ project_id: z.string().describe("The project ID"),
2315
+ app_user_id: z.string().describe("The user to remove the item from"),
2316
+ catalog_item_id: z.string().describe("The catalog item ID to revoke"),
2317
+ quantity: z.number().optional().describe("Quantity to revoke (default 1)")
2318
+ }, async ({ project_id, app_user_id, catalog_item_id, quantity }, { client }) => {
2319
+ const payload = {
2320
+ app_user_id,
2321
+ catalog_item_id
2322
+ };
2323
+ if (quantity !== void 0) payload.quantity = quantity;
2324
+ const result = await client.post(`/projects/${project_id}/inventory/revoke`, payload);
2325
+ return { content: [{
2326
+ type: "text",
2327
+ text: JSON.stringify(result, null, 2)
2328
+ }] };
2329
+ });
1981
2330
  registerTool(server, apiClient, "amba_users_get_inventory", "View a user's inventory including all owned items and quantities.", {
1982
2331
  project_id: z.string().describe("The project ID"),
1983
2332
  app_user_id: z.string().describe("The user ID to look up")
@@ -2266,8 +2615,8 @@ function registerTools$13(server, apiClient) {
2266
2615
  }
2267
2616
  //#endregion
2268
2617
  //#region src/tools/leaderboards.ts
2269
- function registerTools$12(server, apiClient) {
2270
- 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)`.", {
2271
2620
  project_id: z.string().describe("The project ID"),
2272
2621
  name: z.string().describe("Leaderboard name (e.g. \"Top XP Earners\", \"Weekly Streak Leaders\")"),
2273
2622
  metric: z.enum([
@@ -2312,7 +2661,7 @@ function registerTools$12(server, apiClient) {
2312
2661
  text: JSON.stringify(result, null, 2)
2313
2662
  }] };
2314
2663
  }, ["amba_get_leaderboard"]);
2315
- 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 }) => {
2316
2665
  const result = await client.get(`/projects/${project_id}/leaderboards`);
2317
2666
  return { content: [{
2318
2667
  type: "text",
@@ -2371,8 +2720,80 @@ function registerTools$12(server, apiClient) {
2371
2720
  }, ["amba_delete_leaderboard"]);
2372
2721
  }
2373
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
2374
2795
  //#region src/tools/platform.ts
2375
- function registerTools$11(server, apiClient) {
2796
+ function registerTools$18(server, apiClient) {
2376
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).", {
2377
2798
  project_id: z.string().describe("The project ID"),
2378
2799
  name: z.string().describe("Flow name (e.g. \"Welcome Flow\", \"Premium Onboarding\")"),
@@ -2791,6 +3212,44 @@ function registerTools$11(server, apiClient) {
2791
3212
  text: JSON.stringify(result, null, 2)
2792
3213
  }] };
2793
3214
  });
3215
+ registerTool(server, apiClient, "amba_messaging_create_conversation", "Create a conversation server-side. Unlike the client path, this allows ZERO or more initial participants — a matchmaker can create an empty circle then populate it over time. Returns the new conversation row.", {
3216
+ project_id: z.string().describe("The project ID"),
3217
+ type: z.enum(["direct", "group"]).optional().describe("Conversation type. Defaults to \"group\"."),
3218
+ name: z.string().optional().describe("Optional conversation name (group conversations)."),
3219
+ participant_ids: z.array(z.string()).optional().describe("Initial participant app_user IDs. May be empty or omitted.")
3220
+ }, async ({ project_id, type, name, participant_ids }, { client }) => {
3221
+ const payload = {};
3222
+ if (type !== void 0) payload.type = type;
3223
+ if (name !== void 0) payload.name = name;
3224
+ if (participant_ids !== void 0) payload.participant_ids = participant_ids;
3225
+ const result = await client.post(`/projects/${project_id}/messaging/conversations`, payload);
3226
+ return { content: [{
3227
+ type: "text",
3228
+ text: JSON.stringify(result, null, 2)
3229
+ }] };
3230
+ });
3231
+ registerTool(server, apiClient, "amba_messaging_add_participant", "Add a user to an existing conversation (idempotent). The matchmaker primitive: as each user qualifies for a circle, add them to its conversation so they can read, send, and receive messages. Re-adding an existing member is a no-op.", {
3232
+ project_id: z.string().describe("The project ID"),
3233
+ conversation_id: z.string().describe("The conversation ID"),
3234
+ user_id: z.string().describe("The app_user ID to add")
3235
+ }, async ({ project_id, conversation_id, user_id }, { client }) => {
3236
+ const result = await client.post(`/projects/${project_id}/messaging/conversations/${conversation_id}/participants`, { user_id });
3237
+ return { content: [{
3238
+ type: "text",
3239
+ text: JSON.stringify(result, null, 2)
3240
+ }] };
3241
+ });
3242
+ registerTool(server, apiClient, "amba_messaging_remove_participant", "Remove a user from a conversation (idempotent inverse of add). Removing a non-member returns removed: false.", {
3243
+ project_id: z.string().describe("The project ID"),
3244
+ conversation_id: z.string().describe("The conversation ID"),
3245
+ user_id: z.string().describe("The app_user ID to remove")
3246
+ }, async ({ project_id, conversation_id, user_id }, { client }) => {
3247
+ const result = await client.delete(`/projects/${project_id}/messaging/conversations/${conversation_id}/participants/${user_id}`);
3248
+ return { content: [{
3249
+ type: "text",
3250
+ text: JSON.stringify(result, null, 2)
3251
+ }] };
3252
+ });
2794
3253
  registerTool(server, apiClient, "amba_messaging_list_conversations", "List message conversations for a project, newest first. Pagination via limit+offset.", {
2795
3254
  project_id: z.string().describe("The project ID"),
2796
3255
  limit: z.number().optional().describe("Max results to return (default 50)"),
@@ -2910,7 +3369,7 @@ function registerTools$11(server, apiClient) {
2910
3369
  }
2911
3370
  //#endregion
2912
3371
  //#region src/tools/social.ts
2913
- function registerTools$10(server, apiClient) {
3372
+ function registerTools$17(server, apiClient) {
2914
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 }) => {
2915
3374
  const result = await client.get(`/projects/${project_id}/friends/stats`);
2916
3375
  return { content: [{
@@ -3235,7 +3694,7 @@ function registerTools$10(server, apiClient) {
3235
3694
  }
3236
3695
  //#endregion
3237
3696
  //#region src/tools/xp.ts
3238
- function registerTools$9(server, apiClient) {
3697
+ function registerTools$16(server, apiClient) {
3239
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.", {
3240
3699
  project_id: z.string().describe("The project ID"),
3241
3700
  name: z.string().describe("Rule name (e.g. \"Workout Completed\", \"Daily Login Bonus\")"),
@@ -3307,6 +3766,40 @@ function registerTools$9(server, apiClient) {
3307
3766
  text: JSON.stringify(result, null, 2)
3308
3767
  }] };
3309
3768
  });
3769
+ registerTool(server, apiClient, "amba_xp_grant", "Directly add XP to a user's total — for rewards, promos, or support corrections. This is a manual adjustment: it does NOT replay rule side-effects (level-up events, achievement re-evaluation) the way event-driven XP does. The user's displayed level is still recomputed from the new total. The amount is a positive magnitude; the ledger records it as a positive delta.", {
3770
+ project_id: z.string().describe("The project ID"),
3771
+ app_user_id: z.string().describe("The user to grant XP to"),
3772
+ amount: z.number().describe("Positive amount of XP to add"),
3773
+ reason: z.string().optional().describe("Reason for the grant (stored on the ledger row for the audit trail)")
3774
+ }, async ({ project_id, app_user_id, amount, reason }, { client }) => {
3775
+ const payload = {
3776
+ app_user_id,
3777
+ amount
3778
+ };
3779
+ if (reason !== void 0) payload.reason = reason;
3780
+ const result = await client.post(`/projects/${project_id}/xp/grant`, payload);
3781
+ return { content: [{
3782
+ type: "text",
3783
+ text: JSON.stringify(result, null, 2)
3784
+ }] };
3785
+ });
3786
+ registerTool(server, apiClient, "amba_xp_deduct", "Directly subtract XP from a user's total — the inverse of grant. Atomic: returns 400 INSUFFICIENT_XP if the deduction would drive the total below zero (no partial deduct). Like grant, this is a manual adjustment that does NOT replay rule side-effects. The amount is a positive magnitude; the ledger records it as a negative delta.", {
3787
+ project_id: z.string().describe("The project ID"),
3788
+ app_user_id: z.string().describe("The user to deduct XP from"),
3789
+ amount: z.number().describe("Positive amount of XP to subtract"),
3790
+ reason: z.string().optional().describe("Reason for the deduction (stored on the ledger row for the audit trail)")
3791
+ }, async ({ project_id, app_user_id, amount, reason }, { client }) => {
3792
+ const payload = {
3793
+ app_user_id,
3794
+ amount
3795
+ };
3796
+ if (reason !== void 0) payload.reason = reason;
3797
+ const result = await client.post(`/projects/${project_id}/xp/deduct`, payload);
3798
+ return { content: [{
3799
+ type: "text",
3800
+ text: JSON.stringify(result, null, 2)
3801
+ }] };
3802
+ });
3310
3803
  registerTool(server, apiClient, "amba_xp_list_users", "List users by total XP (leaderboard view). Ordered by total_xp descending with pagination.", {
3311
3804
  project_id: z.string().describe("The project ID"),
3312
3805
  limit: z.number().optional().describe("Max results to return (default 50)"),
@@ -3324,7 +3817,7 @@ function registerTools$9(server, apiClient) {
3324
3817
  }
3325
3818
  //#endregion
3326
3819
  //#region src/tools/events.ts
3327
- function registerTools$8(server, apiClient) {
3820
+ function registerTools$15(server, apiClient) {
3328
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.", {
3329
3822
  project_id: z.string().describe("The project ID"),
3330
3823
  since: z.string().optional().describe("ISO-8601 lower bound for occurred_at. Defaults to 24h ago."),
@@ -3366,57 +3859,176 @@ function registerTools$8(server, apiClient) {
3366
3859
  });
3367
3860
  }
3368
3861
  //#endregion
3369
- //#region src/lib/tool-result.ts
3370
- /**
3371
- * Shared MCP tool result helpers.
3372
- *
3373
- * Every tool emits the same `{ content: [{ type: 'text', text: <json> }] }`
3374
- * envelope. Two helpers centralize that:
3375
- *
3376
- * - [`jsonResult`] — wraps an arbitrary payload.
3377
- * - [`passthroughResult`] — flattens an upstream HTTP response
3378
- * (status + parsed body) into the same envelope. Status is written
3379
- * LAST so a colliding top-level `status` field in the API response
3380
- * cannot shadow the HTTP status — agents look at `parsed.status` to
3381
- * distinguish 2xx from 4xx/5xx.
3382
- *
3383
- * Lives in `src/lib/` (vs. `src/tools/_helpers.ts`) to set the same
3384
- * cross-cutting-helper precedent as `src/lib/with-pat.ts` (task #36).
3385
- * `tools/*` files stay strictly tool registrations.
3386
- */
3387
- /**
3388
- * Wire-shape every MCP tool handler returns.
3389
- *
3390
- * The MCP SDK's `tool()` callback signature is structurally typed and
3391
- * carries an open index signature for `_meta` etc. Declaring our return
3392
- * type as a plain `{ content: [...] }` interface won't satisfy that
3393
- * structural check — so the helpers' return type is left as the actual
3394
- * inferred shape (no explicit interface) and consumers rely on the
3395
- * inference + the SDK's structural compatibility. If we ever want a
3396
- * named alias, write it as a type-alias over the inferred shape rather
3397
- * than a closed interface.
3398
- */
3399
- /** Wrap an arbitrary payload as a JSON-text tool result. */
3400
- function jsonResult$1(payload) {
3401
- return { content: [{
3402
- type: "text",
3403
- text: JSON.stringify(payload, null, 2)
3404
- }] };
3405
- }
3406
- /**
3407
- * Flatten an upstream HTTP response into the agent-facing tool payload.
3408
- * Body fields are spread FIRST so a future top-level `status` key in
3409
- * the API response cannot shadow the HTTP `status` — agents read
3410
- * `parsed.status` to distinguish 2xx from 4xx/5xx.
3411
- */
3412
- function passthroughResult(result) {
3413
- return jsonResult$1({
3414
- ...result.body ?? {},
3415
- status: result.status
3416
- });
3417
- }
3418
- //#endregion
3419
- //#region src/tools/auth.ts
3862
+ //#region src/tools/funnels.ts
3863
+ const funnelFilterSchema = z.object({
3864
+ property: z.string().describe("Dot-path into the event `properties` JSON (e.g. \"source\" or \"plan.tier\")."),
3865
+ op: z.enum([
3866
+ "eq",
3867
+ "neq",
3868
+ "gt",
3869
+ "gte",
3870
+ "lt",
3871
+ "lte",
3872
+ "contains",
3873
+ "not_contains",
3874
+ "exists",
3875
+ "not_exists"
3876
+ ]).describe("Comparison operator. exists/not_exists ignore `value`."),
3877
+ value: z.union([
3878
+ z.string(),
3879
+ z.number(),
3880
+ z.boolean()
3881
+ ]).optional().describe("Scalar to compare against. Required for every op except exists/not_exists.")
3882
+ });
3883
+ const funnelStepSchema = z.object({
3884
+ event: z.string().describe("Event name to match (engagement event_name)."),
3885
+ filters: z.array(funnelFilterSchema).optional().describe("Optional property predicates, ANDed together, that an event must satisfy.")
3886
+ });
3887
+ function registerTools$14(server, apiClient) {
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 }) => {
3889
+ const result = await client.get(`/projects/${project_id}/funnels`);
3890
+ return { content: [{
3891
+ type: "text",
3892
+ text: JSON.stringify(result, null, 2)
3893
+ }] };
3894
+ });
3895
+ registerTool(server, apiClient, "amba_funnels_create", [
3896
+ "Create a conversion funnel definition. Steps are ORDERED: a user is credited with",
3897
+ "step N only if they emitted step N after their matched step N-1, all within the",
3898
+ "conversion window measured from their first step-0 event. conversion_window_seconds",
3899
+ "defaults to 604800 (7 days). Needs at least 2 steps."
3900
+ ].join(" "), {
3901
+ project_id: z.string().describe("The project ID"),
3902
+ name: z.string().describe("Funnel name (unique per project)."),
3903
+ description: z.string().optional().describe("Human-readable description."),
3904
+ steps: z.array(funnelStepSchema).describe("Ordered steps (2-10). Each step is an event + optional property filters."),
3905
+ conversion_window_seconds: z.number().int().optional().describe("Conversion window in seconds (60 .. 7776000). Defaults to 604800 (7 days).")
3906
+ }, async ({ project_id, name, description, steps, conversion_window_seconds }, { client }) => {
3907
+ const payload = {
3908
+ name,
3909
+ steps
3910
+ };
3911
+ if (description !== void 0) payload.description = description;
3912
+ if (conversion_window_seconds !== void 0) payload.conversion_window_seconds = conversion_window_seconds;
3913
+ const result = await client.post(`/projects/${project_id}/funnels`, payload);
3914
+ return { content: [{
3915
+ type: "text",
3916
+ text: JSON.stringify(result, null, 2)
3917
+ }] };
3918
+ });
3919
+ registerTool(server, apiClient, "amba_funnels_get", "Get a single funnel definition by id (name, ordered steps, conversion window).", {
3920
+ project_id: z.string().describe("The project ID"),
3921
+ funnel_id: z.string().describe("The funnel ID")
3922
+ }, async ({ project_id, funnel_id }, { client }) => {
3923
+ const result = await client.get(`/projects/${project_id}/funnels/${funnel_id}`);
3924
+ return { content: [{
3925
+ type: "text",
3926
+ text: JSON.stringify(result, null, 2)
3927
+ }] };
3928
+ });
3929
+ registerTool(server, apiClient, "amba_funnels_update", "Update a funnel's name, description, steps, and/or conversion window. Only the provided fields change; steps (when supplied) replace the whole ordered list.", {
3930
+ project_id: z.string().describe("The project ID"),
3931
+ funnel_id: z.string().describe("The funnel ID"),
3932
+ name: z.string().optional().describe("New funnel name."),
3933
+ description: z.string().nullable().optional().describe("New description; pass null to clear."),
3934
+ steps: z.array(funnelStepSchema).optional().describe("Replacement ordered step list (2-10)."),
3935
+ conversion_window_seconds: z.number().int().optional().describe("New conversion window in seconds (60 .. 7776000).")
3936
+ }, async ({ project_id, funnel_id, name, description, steps, conversion_window_seconds }, { client }) => {
3937
+ const payload = {};
3938
+ if (name !== void 0) payload.name = name;
3939
+ if (description !== void 0) payload.description = description;
3940
+ if (steps !== void 0) payload.steps = steps;
3941
+ if (conversion_window_seconds !== void 0) payload.conversion_window_seconds = conversion_window_seconds;
3942
+ const result = await client.patch(`/projects/${project_id}/funnels/${funnel_id}`, payload);
3943
+ return { content: [{
3944
+ type: "text",
3945
+ text: JSON.stringify(result, null, 2)
3946
+ }] };
3947
+ });
3948
+ registerTool(server, apiClient, "amba_funnels_delete", "Delete a funnel definition by id.", {
3949
+ project_id: z.string().describe("The project ID"),
3950
+ funnel_id: z.string().describe("The funnel ID")
3951
+ }, async ({ project_id, funnel_id }, { client }) => {
3952
+ const result = await client.delete(`/projects/${project_id}/funnels/${funnel_id}`);
3953
+ return { content: [{
3954
+ type: "text",
3955
+ text: JSON.stringify(result, null, 2)
3956
+ }] };
3957
+ });
3958
+ registerTool(server, apiClient, "amba_funnels_query", [
3959
+ "Compute a funnel: per-step distinct-user counts, conversion rates, and drop-off over",
3960
+ "an entry date range. `from`/`to` (ISO-8601) bound which step-0 events count as funnel",
3961
+ "entries (default: last 30 days). Each entrant then has their own conversion window in",
3962
+ "which later steps must occur (it may extend past `to`). Returns total_entered,",
3963
+ "total_converted, overall_conversion_rate, and a per-step breakdown."
3964
+ ].join(" "), {
3965
+ project_id: z.string().describe("The project ID"),
3966
+ funnel_id: z.string().describe("The funnel ID"),
3967
+ from: z.string().optional().describe("ISO-8601 lower bound for funnel entries. Defaults to 30 days ago."),
3968
+ to: z.string().optional().describe("ISO-8601 upper bound for funnel entries. Defaults to now.")
3969
+ }, async ({ project_id, funnel_id, from, to }, { client }) => {
3970
+ const query = {};
3971
+ if (from !== void 0) query.from = from;
3972
+ if (to !== void 0) query.to = to;
3973
+ const result = await client.get(`/projects/${project_id}/funnels/${funnel_id}/query`, query);
3974
+ return { content: [{
3975
+ type: "text",
3976
+ text: JSON.stringify(result, null, 2)
3977
+ }] };
3978
+ });
3979
+ }
3980
+ //#endregion
3981
+ //#region src/lib/tool-result.ts
3982
+ /**
3983
+ * Shared MCP tool result helpers.
3984
+ *
3985
+ * Every tool emits the same `{ content: [{ type: 'text', text: <json> }] }`
3986
+ * envelope. Two helpers centralize that:
3987
+ *
3988
+ * - [`jsonResult`] — wraps an arbitrary payload.
3989
+ * - [`passthroughResult`] — flattens an upstream HTTP response
3990
+ * (status + parsed body) into the same envelope. Status is written
3991
+ * LAST so a colliding top-level `status` field in the API response
3992
+ * cannot shadow the HTTP status — agents look at `parsed.status` to
3993
+ * distinguish 2xx from 4xx/5xx.
3994
+ *
3995
+ * Lives in `src/lib/` (vs. `src/tools/_helpers.ts`) to set the same
3996
+ * cross-cutting-helper precedent as `src/lib/with-pat.ts` (task #36).
3997
+ * `tools/*` files stay strictly tool registrations.
3998
+ */
3999
+ /**
4000
+ * Wire-shape every MCP tool handler returns.
4001
+ *
4002
+ * The MCP SDK's `tool()` callback signature is structurally typed and
4003
+ * carries an open index signature for `_meta` etc. Declaring our return
4004
+ * type as a plain `{ content: [...] }` interface won't satisfy that
4005
+ * structural check — so the helpers' return type is left as the actual
4006
+ * inferred shape (no explicit interface) and consumers rely on the
4007
+ * inference + the SDK's structural compatibility. If we ever want a
4008
+ * named alias, write it as a type-alias over the inferred shape rather
4009
+ * than a closed interface.
4010
+ */
4011
+ /** Wrap an arbitrary payload as a JSON-text tool result. */
4012
+ function jsonResult$1(payload) {
4013
+ return { content: [{
4014
+ type: "text",
4015
+ text: JSON.stringify(payload, null, 2)
4016
+ }] };
4017
+ }
4018
+ /**
4019
+ * Flatten an upstream HTTP response into the agent-facing tool payload.
4020
+ * Body fields are spread FIRST so a future top-level `status` key in
4021
+ * the API response cannot shadow the HTTP `status` — agents read
4022
+ * `parsed.status` to distinguish 2xx from 4xx/5xx.
4023
+ */
4024
+ function passthroughResult(result) {
4025
+ return jsonResult$1({
4026
+ ...result.body ?? {},
4027
+ status: result.status
4028
+ });
4029
+ }
4030
+ //#endregion
4031
+ //#region src/tools/auth.ts
3420
4032
  async function authFetch(apiClient, options) {
3421
4033
  const url = `${apiClient.getApiRoot()}${options.path}`;
3422
4034
  const headers = { Accept: "application/json" };
@@ -3538,7 +4150,7 @@ function enrichedAuthResult(result, agentInstructions) {
3538
4150
  status: result.status
3539
4151
  });
3540
4152
  }
3541
- function registerTools$7(server, apiClient) {
4153
+ function registerTools$13(server, apiClient) {
3542
4154
  registerPublicTool(server, "amba_developer_signup", [
3543
4155
  "Create a new Amba developer account. Returns a long-lived Personal Access Token (PAT)",
3544
4156
  "plus a real isolated Amba project (provisioning asynchronously), plus ready-to-paste",
@@ -3672,6 +4284,18 @@ async function adminFetch(apiClient, options) {
3672
4284
  body
3673
4285
  };
3674
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
+ }
3675
4299
  async function clientFetch(apiClient, options) {
3676
4300
  let url = `${apiClient.getApiRoot()}/client${options.path}`;
3677
4301
  if (options.query && Object.keys(options.query).length > 0) url += `?${new URLSearchParams(options.query).toString()}`;
@@ -3709,7 +4333,7 @@ const MISSING_API_KEY_ERROR = jsonResult$1({
3709
4333
  });
3710
4334
  const columnSchema = z.object({
3711
4335
  name: z.string().describe("Column name. Must match /^[a-z][a-z0-9_]*$/."),
3712
- 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."),
3713
4337
  nullable: z.boolean().optional().describe("Whether the column accepts NULL. Defaults to true."),
3714
4338
  default: z.unknown().optional().describe("Default SQL literal (e.g. \"NOW()\", 0, \"''\"). Optional."),
3715
4339
  dimensions: z.number().optional().describe("Required for `vector` columns; ignored otherwise.")
@@ -3735,7 +4359,7 @@ const sdkOrderSchema = z.array(z.object({
3735
4359
  })).describe("Ordering. `[{column: \"created_at\", direction: \"desc\"}, ...]`.");
3736
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.");
3737
4361
  const setSchema = z.record(z.unknown()).describe("Column-value map. Server-managed columns (id, created_at, etc.) are rejected.");
3738
- function registerTools$6(server, apiClient) {
4362
+ function registerTools$12(server, apiClient) {
3739
4363
  registerTool(server, apiClient, "amba_collections_create", [
3740
4364
  "Create a new collection (schema-first Postgres table) in a project.",
3741
4365
  "The DDL is emitted server-side and applied via a Temporal saga.",
@@ -3746,13 +4370,15 @@ function registerTools$6(server, apiClient) {
3746
4370
  project_id: z.string().describe("The project ID owning the collection."),
3747
4371
  name: z.string().describe("Collection name. Must match /^[a-z][a-z0-9_]*$/. Reserved prefixes (amba_, coll_amba_) are rejected."),
3748
4372
  columns: z.array(columnSchema).describe("Column definitions. Server adds id/user_id/created_at/updated_at/deleted_at."),
3749
- indexes: z.array(indexSchema).optional().describe("Optional extra indexes (server creates a primary key + user_id index by default).")
3750
- }, async ({ project_id, name, columns, indexes }, { pat }) => {
4373
+ indexes: z.array(indexSchema).optional().describe("Optional extra indexes (server creates a primary key + user_id index by default)."),
4374
+ shared: z.boolean().optional().describe("Set true for developer-seeded GLOBAL content not owned by any single user (question banks, content libraries, lookup tables). Makes user_id NULLABLE so rows can be inserted without a user_id. Defaults to false (every row requires a user_id).")
4375
+ }, async ({ project_id, name, columns, indexes, shared }, { pat }) => {
3751
4376
  const body = {
3752
4377
  name,
3753
4378
  columns
3754
4379
  };
3755
4380
  if (indexes !== void 0) body.indexes = indexes;
4381
+ if (shared !== void 0) body.shared = shared;
3756
4382
  return passthroughResult(await adminFetch(apiClient, {
3757
4383
  method: "POST",
3758
4384
  path: `/projects/${project_id}/collections`,
@@ -3810,22 +4436,39 @@ function registerTools$6(server, apiClient) {
3810
4436
  }));
3811
4437
  }, ["amba_delete_collection"]);
3812
4438
  registerTool(server, apiClient, "amba_collections_alter", [
3813
- "Alter a collection: exactly one of `add_column`, `add_index`, or `drop_column` per call (the underlying saga is built around one SQL blob per workflow).",
4439
+ "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).",
3814
4440
  "Drop-column is destructive — requires `confirm` to equal the dropped column name.",
4441
+ "change_type changes an existing column to a new type. It REWRITES THE WHOLE TABLE under an exclusive lock — the collection is briefly unreadable/unwritable while it runs, so prefer low-traffic windows for large collections. Casts that are not trivially safe (e.g. text→integer) require a `using` cast expression; known-safe casts (anything→text, integer→bigint, integer/bigint→numeric) do not. Without a `using` for an unsafe cast you get a 400 up front rather than a mid-migration failure.",
4442
+ "rename_column renames a column (metadata-only, fast). It BREAKS any app code referencing the old name — re-run `amba types generate` and update call sites afterward. The new name can’t collide with a server-managed column (id, user_id, created_at, updated_at, deleted_at) or a reserved query keyword (and, or, not).",
4443
+ "relax_user_id converts an EXISTING collection to shared (drops the user_id NOT NULL so global rows can be inserted) — the post-hoc equivalent of creating it with shared:true.",
3815
4444
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
3816
- "Errors: 400 INVALID_PATCH if not exactly one operation, 400 CONFIRMATION_REQUIRED on drop_column without matching confirm."
4445
+ "Errors: 400 INVALID_PATCH if not exactly one operation, 400 CONFIRMATION_REQUIRED on drop_column without matching confirm, 400 INVALID_COLLECTION_SCHEMA on an unsafe change_type cast or a reserved rename target, 400 COLLECTION_MIGRATION_FAILED if the cast fails at apply time."
3817
4446
  ].join(" "), {
3818
4447
  project_id: z.string().describe("The project ID."),
3819
4448
  name: z.string().describe("Collection name to alter."),
3820
- add_column: columnSchema.optional().describe("Column to add. Mutually exclusive with add_index / drop_column."),
3821
- add_index: indexSchema.optional().describe("Index to add. Mutually exclusive with add_column / drop_column."),
4449
+ add_column: columnSchema.optional().describe("Column to add. Mutually exclusive with the other alter operations."),
4450
+ add_index: indexSchema.optional().describe("Index to add. Mutually exclusive with the other alter operations."),
4451
+ change_type: z.object({
4452
+ column: z.string().describe("Existing column to change."),
4453
+ to_type: z.string().describe("Target type (text, integer, bigint, numeric, boolean, timestamptz, date, jsonb, uuid)."),
4454
+ from_type: z.string().optional().describe("Optional current type. When provided, known-safe widening casts (integer→bigint, integer/bigint→numeric) skip the `using` requirement."),
4455
+ using: z.string().optional().describe("SQL cast expression for the USING clause, e.g. \"score::integer\". Required for casts that are not trivially safe; omit only for anything→text or a known-safe widening cast.")
4456
+ }).optional().describe("Change a column’s type. REWRITES THE WHOLE TABLE under an exclusive lock (briefly unreadable/unwritable). Mutually exclusive with the other alter operations."),
4457
+ rename_column: z.object({
4458
+ from: z.string().describe("Current column name."),
4459
+ to: z.string().describe("New column name. Must match /^[a-z][a-z0-9_]*$/.")
4460
+ }).optional().describe("Rename a column (metadata-only, fast). BREAKS code referencing the old name — re-run `amba types generate` afterward. Mutually exclusive with the other alter operations."),
3822
4461
  drop_column: z.string().optional().describe("Column name to drop. Requires `confirm` query param to match."),
4462
+ relax_user_id: z.boolean().optional().describe("Set true to drop the user_id NOT NULL constraint so this collection can hold shared/global rows (NULL user_id). Mutually exclusive with the other operations."),
3823
4463
  confirm: z.string().optional().describe("Required when drop_column is set; must equal the column name.")
3824
- }, async ({ project_id, name, add_column, add_index, drop_column, confirm }, { pat }) => {
4464
+ }, async ({ project_id, name, add_column, add_index, change_type, rename_column, drop_column, relax_user_id, confirm }, { pat }) => {
3825
4465
  const body = {};
3826
4466
  if (add_column !== void 0) body.add_column = add_column;
3827
4467
  if (add_index !== void 0) body.add_index = add_index;
4468
+ if (change_type !== void 0) body.change_type = change_type;
4469
+ if (rename_column !== void 0) body.rename_column = rename_column;
3828
4470
  if (drop_column !== void 0) body.drop_column = drop_column;
4471
+ if (relax_user_id !== void 0) body.relax_user_id = relax_user_id;
3829
4472
  const query = {};
3830
4473
  if (drop_column !== void 0 && confirm !== void 0) query.confirm = confirm;
3831
4474
  return passthroughResult(await adminFetch(apiClient, {
@@ -3839,23 +4482,67 @@ function registerTools$6(server, apiClient) {
3839
4482
  registerTool(server, apiClient, "amba_admin_insert_row", [
3840
4483
  "Insert a row directly into a collection from the developer/agent side — BYPASSES auto-RLS.",
3841
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.",
3842
4486
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
3843
- "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."
3844
4488
  ].join(" "), {
3845
4489
  project_id: z.string().describe("The project ID."),
3846
4490
  name: z.string().describe("Collection name."),
3847
- row: z.record(z.unknown()).describe("Column-value map for the new row. Server-managed columns are stripped.")
3848
- }, async ({ project_id, name, row }, { pat }) => {
3849
- 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, {
3850
4505
  method: "POST",
3851
4506
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
3852
4507
  body: row,
4508
+ query: Object.keys(query).length > 0 ? query : void 0,
4509
+ bearer: pat
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;
4518
+ });
4519
+ registerTool(server, apiClient, "amba_admin_insert_rows", [
4520
+ "Bulk-insert up to 500 rows into a collection in a single atomic statement — BYPASSES auto-RLS.",
4521
+ "Use for developer-seeded data (question banks, content libraries, lookup tables); far faster than calling amba_admin_insert_row in a loop.",
4522
+ "Server-managed columns (id, created_at, updated_at, deleted_at) are stripped per row; `user_id` is honored if present. Rows may be sparse — a column absent from a row is inserted as NULL.",
4523
+ "on_conflict \"error\" (default) aborts the whole batch on any conflict; \"skip\" emits ON CONFLICT DO NOTHING and reports how many rows were skipped.",
4524
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4525
+ "Errors: 400 BATCH_TOO_LARGE (>500) / INVALID_COLUMN / NOT_NULL_VIOLATION, 404 COLLECTION_NOT_FOUND, 409 CONFLICT (on_conflict:\"error\")."
4526
+ ].join(" "), {
4527
+ project_id: z.string().describe("The project ID."),
4528
+ name: z.string().describe("Collection name."),
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."),
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 }) => {
4533
+ const body = { rows };
4534
+ if (on_conflict !== void 0) body.on_conflict = on_conflict;
4535
+ return passthroughResult(await adminFetch(apiClient, {
4536
+ method: "POST",
4537
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows:batch`,
4538
+ body,
4539
+ query: return_minimal ? { return: "minimal" } : void 0,
3853
4540
  bearer: pat
3854
4541
  }));
3855
4542
  });
3856
4543
  registerTool(server, apiClient, "amba_admin_list_rows", [
3857
4544
  "List rows in a collection from the admin side. BYPASSES auto-RLS — every row in the collection is visible.",
3858
- "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\").",
3859
4546
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
3860
4547
  "Supports the server FindQuery DSL via `where` + `order` + `limit` + `offset` + `cursor` + `select`."
3861
4548
  ].join(" "), {
@@ -3867,7 +4554,7 @@ function registerTools$6(server, apiClient) {
3867
4554
  offset: z.number().optional().describe("Offset for pagination."),
3868
4555
  cursor: z.string().optional().describe("Opaque cursor returned by a prior page."),
3869
4556
  select: z.array(z.string()).optional().describe("Subset of columns to return."),
3870
- 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.")
3871
4558
  }, async ({ project_id, name, where, order, limit, offset, cursor, select, include_deleted }, { pat }) => {
3872
4559
  const findQuery = {};
3873
4560
  if (where !== void 0) findQuery.where = where;
@@ -3884,21 +4571,67 @@ function registerTools$6(server, apiClient) {
3884
4571
  bearer: pat
3885
4572
  }));
3886
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
+ });
3887
4609
  server.tool("amba_client_insert_row", [
3888
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.).",
3889
4612
  "Authenticates as the end-user — pass `api_key` (project client X-Api-Key) plus `session_token` (the app_user's Bearer).",
3890
- "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."
3891
4614
  ].join(" "), {
3892
4615
  name: z.string().describe("Collection name."),
3893
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."),
3894
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."),
3895
4624
  session_token: z.string().optional().describe("Optional app_user session Bearer token. Required by the route's clientSessionAuth.")
3896
- }, async ({ name, row, api_key, session_token }) => {
4625
+ }, async ({ name, row, on_conflict, conflict_target, api_key, session_token }) => {
3897
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(",");
3898
4630
  return passthroughResult(await clientFetch(apiClient, {
3899
4631
  method: "POST",
3900
4632
  path: `/collections/${encodeURIComponent(name)}`,
3901
4633
  body: row,
4634
+ ...Object.keys(query).length > 0 && { query },
3902
4635
  apiKey: api_key,
3903
4636
  ...session_token !== void 0 && { sessionToken: session_token }
3904
4637
  }));
@@ -3956,6 +4689,7 @@ function registerTools$6(server, apiClient) {
3956
4689
  });
3957
4690
  server.tool("amba_client_update_row", [
3958
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.",
3959
4693
  "For bulk updates with a where-clause, omit `id` and pass `where` + `set`.",
3960
4694
  "Server-managed columns and user_id are rejected with 400 INVALID_COLUMN.",
3961
4695
  "Authenticates as the end-user — pass `api_key` + `session_token`."
@@ -3964,15 +4698,19 @@ function registerTools$6(server, apiClient) {
3964
4698
  id: z.string().optional().describe("Row UUID for single-row update. Omit to bulk update via `where`."),
3965
4699
  where: adminFindWhereSchema.optional().describe("Required for bulk update (when `id` is omitted). `where: {}` matches all rows the user owns."),
3966
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."),
3967
4702
  limit: z.number().optional().describe("Bulk-update cap (default 1000, max 10000). Ignored when `id` is set."),
3968
4703
  api_key: z.string().describe("Project client X-Api-Key."),
3969
4704
  session_token: z.string().optional().describe("App_user session Bearer. Required by the route.")
3970
- }, async ({ name, id, where, set, limit, api_key, session_token }) => {
4705
+ }, async ({ name, id, where, set, expected, limit, api_key, session_token }) => {
3971
4706
  if (!api_key) return MISSING_API_KEY_ERROR;
3972
4707
  if (id !== void 0) return passthroughResult(await clientFetch(apiClient, {
3973
4708
  method: "PATCH",
3974
4709
  path: `/collections/${encodeURIComponent(name)}/${encodeURIComponent(id)}`,
3975
- body: { set },
4710
+ body: expected !== void 0 ? {
4711
+ set,
4712
+ expected
4713
+ } : { set },
3976
4714
  apiKey: api_key,
3977
4715
  ...session_token !== void 0 && { sessionToken: session_token }
3978
4716
  }));
@@ -4043,24 +4781,32 @@ function registerTools$6(server, apiClient) {
4043
4781
  });
4044
4782
  server.tool("amba_client_find_rows", [
4045
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.",
4046
- "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: {...}}`.",
4047
- "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).",
4048
4787
  "Authenticates as the end-user — pass `api_key` + `session_token`."
4049
4788
  ].join(" "), {
4050
4789
  name: z.string().describe("Collection name."),
4051
4790
  filter: sdkFilterSchema.optional(),
4052
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."),
4053
4798
  limit: z.number().optional().describe("Max rows."),
4054
4799
  cursor: z.string().optional().describe("Opaque cursor returned by a prior page."),
4055
4800
  select: z.array(z.string()).optional().describe("Subset of columns to return."),
4056
4801
  include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default FALSE."),
4057
4802
  api_key: z.string().describe("Project client X-Api-Key."),
4058
4803
  session_token: z.string().optional().describe("App_user session Bearer. Required by the route.")
4059
- }, 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 }) => {
4060
4805
  if (!api_key) return MISSING_API_KEY_ERROR;
4061
4806
  const body = {};
4062
4807
  if (filter !== void 0) body.filter = filter;
4063
4808
  if (order !== void 0) body.order = order;
4809
+ if (search !== void 0) body.search = search;
4064
4810
  if (limit !== void 0) body.limit = limit;
4065
4811
  if (cursor !== void 0) body.cursor = cursor;
4066
4812
  if (select !== void 0) body.select = select;
@@ -4232,24 +4978,27 @@ function jsonResult(payload) {
4232
4978
  * of a generic upstream 400.
4233
4979
  */
4234
4980
  const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
4235
- function registerTools$5(server, apiClient) {
4981
+ function registerTools$11(server, apiClient) {
4236
4982
  registerTool(server, apiClient, "amba_functions_deploy", [
4237
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`.",
4238
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).",
4239
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.",
4240
4987
  "Returns the function deployment row + the public URL (`fn_url`: `https://{project_slug}.fn.amba.host/{name}`)."
4241
4988
  ].join(" "), {
4242
4989
  project_id: z.string().describe("The Amba project ID."),
4243
4990
  name: z.string().describe("Function name. Lowercase, /^[a-z][a-z0-9_-]*$/, ≤58 chars."),
4244
4991
  code: z.string().describe("Bundled JavaScript module source (one entry file)."),
4245
- rate_limit: z.unknown().optional().describe("Optional rate-limit config object. Shape: see @layers/amba-shared:RateLimitConfig.")
4246
- }, 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 }) => {
4247
4995
  const byteLen = Buffer.byteLength(code, "utf8");
4248
4996
  if (byteLen > MAX_BUNDLE_BYTES) throw new Error(`Bundle size ${byteLen} bytes exceeds ${MAX_BUNDLE_BYTES}-byte cap (10 MiB).`);
4249
4997
  const form = new FormData();
4250
4998
  form.append("script", new Blob([code], { type: "application/javascript" }), "bundle.js");
4251
4999
  const metadata = { name };
4252
5000
  if (rate_limit !== void 0 && rate_limit !== null) metadata.rate_limit = rate_limit;
5001
+ if (isPublic === true) metadata.public = true;
4253
5002
  form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
4254
5003
  return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/functions/deploy`, { formData: form }));
4255
5004
  }, ["amba_deploy_function"]);
@@ -4357,7 +5106,7 @@ function registerTools$5(server, apiClient) {
4357
5106
  }
4358
5107
  //#endregion
4359
5108
  //#region src/tools/sites.ts
4360
- function registerTools$4(server, apiClient) {
5109
+ function registerTools$10(server, apiClient) {
4361
5110
  registerTool(server, apiClient, "amba_sites_deploy", [
4362
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.",
4363
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`.",
@@ -4448,22 +5197,81 @@ function registerTools$4(server, apiClient) {
4448
5197
  }, ["amba_remove_site_domain"]);
4449
5198
  }
4450
5199
  //#endregion
5200
+ //#region src/tools/domains.ts
5201
+ function registerTools$9(server, apiClient) {
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(" "), {
5203
+ project_id: z.string().describe("The Amba project ID."),
5204
+ query: z.string().describe("A keyword (e.g. \"getunbury\") or a full domain (e.g. \"unbury.com\")."),
5205
+ limit: z.number().int().min(1).max(50).optional().describe("Max suggestions (1-50, default 20).")
5206
+ }, async ({ project_id, query, limit }, { pat }) => {
5207
+ const q = { q: query };
5208
+ if (limit !== void 0) q.limit = String(limit);
5209
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/domains/search`, { query: q }));
5210
+ });
5211
+ registerTool(server, apiClient, "amba_domains_check", ["Check authoritative availability + price for specific domains (up to 20 at once). Free.", "Returns, per domain: whether it is available, the first-year price (USD), whether it is a premium name, and whether Amba can register that extension."].join(" "), {
5212
+ project_id: z.string().describe("The Amba project ID."),
5213
+ domains: z.array(z.string()).min(1).max(20).describe("Domains to check, e.g. [\"unbury.com\", \"unbury.app\"]. Lowercased at the boundary.")
5214
+ }, async ({ project_id, domains }, { pat }) => {
5215
+ const normalised = domains.map((d) => d.toLowerCase());
5216
+ return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/domains/check`, { body: { domains: normalised } }));
5217
+ });
5218
+ registerTool(server, apiClient, "amba_domains_purchase", [
5219
+ "Buy a domain through Amba and connect it to one of the project's sites as a live custom domain — the customer never touches DNS.",
5220
+ "TWO-STEP / MONEY SAFETY: the first call (without `confirm`) returns a QUOTE with the price and `confirmation_required: true` and charges nothing. Surface the price to the user and get their go-ahead, then call again with `confirm: true` and `accept_price_usd` set to the quoted price to execute the purchase.",
5221
+ "A real domain registration spends money and is permanent. If purchasing is not enabled on the deployment, the call returns the quote with `gated: true` and still charges nothing.",
5222
+ "On success: the domain is registered, DNS is auto-configured, and the site begins serving on the new domain (the TLS certificate issues automatically)."
5223
+ ].join(" "), {
5224
+ project_id: z.string().describe("The Amba project ID."),
5225
+ domain: z.string().describe("The domain to buy, e.g. \"unbury.com\". Must be available (check first)."),
5226
+ site: z.string().describe("The site name to connect the domain to (from amba_sites_list)."),
5227
+ accept_price_usd: z.number().optional().describe("Required to execute: the price (USD) you were quoted. Omit on the first (quote) call."),
5228
+ confirm: z.boolean().optional().describe("Set true ONLY after the user has approved the cost. Omit on the first (quote) call."),
5229
+ auto_renew: z.boolean().optional().describe("Auto-renew the domain each year (default true)."),
5230
+ privacy: z.boolean().optional().describe("Enable WHOIS privacy (default true)."),
5231
+ years: z.number().int().min(1).max(10).optional().describe("Registration length in years (default 1).")
5232
+ }, async ({ project_id, domain, site, accept_price_usd, confirm, auto_renew, privacy, years }, { pat }) => {
5233
+ const body = {
5234
+ domain: domain.toLowerCase(),
5235
+ site
5236
+ };
5237
+ if (accept_price_usd !== void 0) body.accept_price_usd = accept_price_usd;
5238
+ if (confirm !== void 0) body.confirm = confirm;
5239
+ if (auto_renew !== void 0) body.auto_renew = auto_renew;
5240
+ if (privacy !== void 0) body.privacy = privacy;
5241
+ if (years !== void 0) body.years = years;
5242
+ return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/domains/purchase`, { body }));
5243
+ });
5244
+ registerTool(server, apiClient, "amba_domains_list", "List domains this project has purchased through Amba, with registration status and the site each is connected to.", {
5245
+ project_id: z.string().describe("The Amba project ID."),
5246
+ limit: z.number().int().min(1).max(200).optional().describe("Page size (1-200, default 50)."),
5247
+ offset: z.number().int().min(0).optional().describe("Skip rows (default 0).")
5248
+ }, async ({ project_id, limit, offset }, { pat }) => {
5249
+ const query = {};
5250
+ if (limit !== void 0) query.limit = String(limit);
5251
+ if (offset !== void 0) query.offset = String(offset);
5252
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/domains`, { query }));
5253
+ });
5254
+ }
5255
+ //#endregion
4451
5256
  //#region src/tools/secrets.ts
4452
- function registerTools$3(server, apiClient) {
5257
+ function registerTools$8(server, apiClient) {
4453
5258
  registerTool(server, apiClient, "amba_secrets_set", [
4454
- "Set or rotate a function-scoped secret. Every secret belongs to exactly one function — the `function` arg is required.",
4455
- "Writes a new version to the backing secret store and enqueues a Workers-side sync. Subsequent function invocations see the new value once sync_status flips to \"synced\" (typically <30s).",
4456
- "Secret name must match /^[A-Z][A-Z0-9_]{0,62}$/. Function name must match /^[a-z][a-z0-9_-]{0,57}$/.",
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.",
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).",
5261
+ "Secret name must match /^[A-Z][A-Z0-9_]{0,62}$/. Function name (when given) must match /^[a-z][a-z0-9_-]{0,57}$/.",
4457
5262
  "Reserved binding names (AMBA_INTERNAL_TOKEN, EDGE_HEADER_SIGNING_SECRET, STORAGE, EDGE_DB_PROXY, AMBA_API_URL, AMBA_AI_GATEWAY_URL, AMBA_PROJECT_ID) are platform-managed and rejected with 400 RESERVED_BINDING.",
4458
- "The function must already have at least one deployment row (deploy it first via `amba_functions_deploy`).",
5263
+ "Secrets may be set BEFORE deploying the function — the sync drains once a deployment lands, so you can set every secret first and deploy once.",
4459
5264
  "Value cap: 64 KiB. Plaintext is never returned afterwards."
4460
5265
  ].join(" "), {
4461
5266
  project_id: z.string().describe("The Amba project ID."),
4462
5267
  name: z.string().describe("Secret name. UPPER_SNAKE_CASE. /^[A-Z][A-Z0-9_]{0,62}$/."),
4463
5268
  value: z.string().describe("Secret value. Stored encrypted at rest; never returned in plaintext afterwards."),
4464
- function: z.string().describe("Function name this secret binds to. Required — secrets are function-scoped.")
5269
+ function: z.string().optional().describe("Function this secret binds to. OMIT for a project-wide secret (applied to every function in the project).")
4465
5270
  }, async ({ project_id, name, value, function: functionName }, { pat }) => {
4466
- return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/secrets`, { body: {
5271
+ return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/secrets`, { body: functionName === void 0 ? {
5272
+ name,
5273
+ value
5274
+ } : {
4467
5275
  name,
4468
5276
  value,
4469
5277
  function: functionName
@@ -4488,25 +5296,26 @@ function registerTools$3(server, apiClient) {
4488
5296
  return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/secrets`));
4489
5297
  }, ["amba_list_secrets"]);
4490
5298
  registerTool(server, apiClient, "amba_secrets_delete", [
4491
- "Delete a function-scoped secret. Removes the version from the backing store AND the sync-pending row. The Workers-side binding is reaped on the next deploy of the function (the binding list is re-emitted without the secret).",
4492
- "The `function` arg is required — secrets are function-scoped.",
5299
+ "Delete a secret. Removes the version from the backing store AND the sync-pending row. The Worker-side binding is reaped on the next deploy of the affected function(s) (the binding list is re-emitted without the secret).",
5300
+ "Pass `function` to delete a function-scoped secret; OMIT it to delete the project-wide secret of this name.",
4493
5301
  "Idempotent: deleting a secret that no longer exists succeeds."
4494
5302
  ].join(" "), {
4495
5303
  project_id: z.string().describe("The Amba project ID."),
4496
5304
  name: z.string().describe("Secret name. /^[A-Z][A-Z0-9_]{0,62}$/."),
4497
- function: z.string().describe("Function name this secret was bound to.")
5305
+ function: z.string().optional().describe("Function this secret was bound to. OMIT to delete the project-wide secret.")
4498
5306
  }, async ({ project_id, name, function: functionName }, { pat }) => {
5307
+ const scope = functionName ?? null;
4499
5308
  try {
4500
- return jsonResult(await callWithPat(apiClient, pat, "DELETE", `/projects/${encodeURIComponent(project_id)}/secrets/${encodeURIComponent(name)}`, { query: { function: functionName } }) ?? {
5309
+ return jsonResult(await callWithPat(apiClient, pat, "DELETE", `/projects/${encodeURIComponent(project_id)}/secrets/${encodeURIComponent(name)}`, functionName === void 0 ? {} : { query: { function: functionName } }) ?? {
4501
5310
  deleted: true,
4502
5311
  name,
4503
- function: functionName
5312
+ function: scope
4504
5313
  });
4505
5314
  } catch (err) {
4506
5315
  if (err instanceof AmbaApiError && err.status === 404) return jsonResult({
4507
5316
  deleted: true,
4508
5317
  name,
4509
- function: functionName,
5318
+ function: scope,
4510
5319
  already_absent: true
4511
5320
  });
4512
5321
  throw err;
@@ -4515,22 +5324,26 @@ function registerTools$3(server, apiClient) {
4515
5324
  }
4516
5325
  //#endregion
4517
5326
  //#region src/tools/ai-prompts-admin.ts
4518
- const PROVIDER_ENUM = z.enum(["anthropic", "openai"]);
5327
+ const PROVIDER_ENUM$1 = z.enum([
5328
+ "anthropic",
5329
+ "openai",
5330
+ "mistral"
5331
+ ]);
4519
5332
  const RATE_LIMIT_SHAPE = z.object({
4520
5333
  window: z.string().optional().describe("Window length (e.g. \"60s\", \"1m\", \"1h\"). Defaults to \"60s\"."),
4521
5334
  max: z.number().int().min(1).optional().describe("Max invocations per window. Defaults to 20."),
4522
5335
  key: z.string().optional().describe("Bucketing key. Typically \"user_id\" or \"session_id\". Defaults to \"user_id\".")
4523
5336
  }).strict();
4524
- function registerTools$2(server, apiClient) {
5337
+ function registerTools$7(server, apiClient) {
4525
5338
  registerTool(server, apiClient, "amba_ai_prompts_create", [
4526
5339
  "Register a new AI prompt template on a project. Returns the persisted prompt row (name, version, provider, model, client_invokable).",
4527
- "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\").",
4528
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.",
4529
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."
4530
5343
  ].join(" "), {
4531
5344
  project_id: z.string().describe("The Amba project ID."),
4532
5345
  name: z.string().describe("Prompt name. /^[a-z][a-z0-9_-]{0,127}$/."),
4533
- 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`."),
4534
5347
  model: z.string().describe("Provider-native model id."),
4535
5348
  system_prompt: z.string().nullable().optional().describe("System prompt sent on every invocation. Omit or null for no system prompt."),
4536
5349
  max_tokens: z.number().int().min(1).max(2e5).optional().describe("Max output tokens per invocation. Defaults to 4096."),
@@ -4560,7 +5373,7 @@ function registerTools$2(server, apiClient) {
4560
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(" "), {
4561
5374
  project_id: z.string().describe("The Amba project ID."),
4562
5375
  name: z.string().describe("Prompt name to update."),
4563
- provider: PROVIDER_ENUM.describe("AI provider."),
5376
+ provider: PROVIDER_ENUM$1.describe("AI provider."),
4564
5377
  model: z.string().describe("Provider-native model id."),
4565
5378
  system_prompt: z.string().nullable().optional().describe("System prompt; null/omit to clear."),
4566
5379
  max_tokens: z.number().int().min(1).max(2e5).optional().describe("Max output tokens."),
@@ -4640,6 +5453,179 @@ function registerTools$2(server, apiClient) {
4640
5453
  }, ["amba_invoke_ai_prompt"]);
4641
5454
  }
4642
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
4643
5629
  //#region src/tools/billing.ts
4644
5630
  /**
4645
5631
  * Static tier catalog. Mirrors the marketing-page structure shipped in
@@ -4723,7 +5709,7 @@ const TIER_CATALOG = {
4723
5709
  note: "Use Amba.track(name, props, { telemetry: true }) for high-volume telemetry. Cheaper, no fan-out to segments/workflows/push."
4724
5710
  }
4725
5711
  };
4726
- function registerTools$1(server, apiClient) {
5712
+ function registerTools$3(server, apiClient) {
4727
5713
  registerTool(server, apiClient, "amba_billing_status", "Get the current billing tier, headroom on each metered axis (MAU, events, push, db_storage, media_storage), projected overage for the current period, spend ceiling, and what (if any) human action is required before the agent can continue safely. Use this BEFORE provisioning new features or running data-heavy workloads so the agent can self-throttle or escalate to the human.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
4728
5714
  const result = await client.get(`/projects/${project_id}/billing/status`);
4729
5715
  return { content: [{
@@ -4750,7 +5736,7 @@ function registerTools$1(server, apiClient) {
4750
5736
  }
4751
5737
  //#endregion
4752
5738
  //#region src/tools/invites.ts
4753
- function registerTools(server, apiClient) {
5739
+ function registerTools$2(server, apiClient) {
4754
5740
  registerTool(server, apiClient, "amba_projects_invite_member", "Invite a teammate by email to collaborate on this project. Returns the invite URL the human should send to their teammate. Roles: admin (can manage members + integrations), member (can write data), viewer (read-only).", {
4755
5741
  project_id: z.string().describe("The project ID"),
4756
5742
  email: z.string().describe("Teammate's email address"),
@@ -4777,7 +5763,7 @@ function registerTools(server, apiClient) {
4777
5763
  text: JSON.stringify(result, null, 2)
4778
5764
  }] };
4779
5765
  });
4780
- registerTool(server, apiClient, "amba_projects_remove_member", "Remove a member from a project. The owner cannot be removed (transfer ownership first via amba_projects_transfer_owner — not yet implemented). Members can remove themselves.", {
5766
+ registerTool(server, apiClient, "amba_projects_remove_member", "Remove a member from a project. The owner cannot be removed — transfer ownership first via amba_projects_transfer_owner, then remove the demoted ex-owner like any admin. Members can remove themselves.", {
4781
5767
  project_id: z.string().describe("The project ID"),
4782
5768
  developer_id: z.string().describe("The developer ID to remove")
4783
5769
  }, async ({ project_id, developer_id }, { client }) => {
@@ -4787,6 +5773,304 @@ function registerTools(server, apiClient) {
4787
5773
  text: JSON.stringify(result, null, 2)
4788
5774
  }] };
4789
5775
  });
5776
+ registerTool(server, apiClient, "amba_projects_transfer_owner", "Transfer project ownership to an existing member. Only the current owner can do this. The new owner must already be a member (invite + accept them first if not). The previous owner is automatically demoted to admin, retaining member and integration management. Use this before removing the current owner — owners cannot be removed directly.", {
5777
+ project_id: z.string().describe("The project ID"),
5778
+ new_owner_developer_id: z.string().describe("Developer ID of the existing member who should become the new owner.")
5779
+ }, async ({ project_id, new_owner_developer_id }, { client }) => {
5780
+ const result = await client.post(`/projects/${project_id}/transfer-ownership`, { new_owner_developer_id });
5781
+ return { content: [{
5782
+ type: "text",
5783
+ text: JSON.stringify(result, null, 2)
5784
+ }] };
5785
+ });
5786
+ }
5787
+ //#endregion
5788
+ //#region src/tools/webhooks.ts
5789
+ function registerTools$1(server, apiClient) {
5790
+ registerTool(server, apiClient, "amba_webhooks_create", "Register an outbound webhook subscription. Amba sends an HMAC-signed POST to target_url every time an event named event_name fires. The signing secret is returned ONCE in this response — copy it into your receiver config; later reads never include it. target_url must be https:// (http:// is allowed only for localhost during development).", {
5791
+ project_id: z.string().describe("The project ID"),
5792
+ event_name: z.string().describe("Event name to subscribe to. Must match ^[a-z][a-z0-9_.-]{0,127}$ (case-insensitive)."),
5793
+ target_url: z.string().describe("HTTPS endpoint that receives the signed POST. http:// allowed only for localhost.")
5794
+ }, async ({ project_id, event_name, target_url }, { client }) => {
5795
+ const result = await client.post(`/projects/${project_id}/webhooks/subscriptions`, {
5796
+ event_name,
5797
+ target_url
5798
+ });
5799
+ return { content: [{
5800
+ type: "text",
5801
+ text: JSON.stringify(result, null, 2)
5802
+ }] };
5803
+ });
5804
+ registerTool(server, apiClient, "amba_webhooks_list", "List all webhook subscriptions for a project (most recent first). Secrets are never included — only id, event_name, target_url, status, and timestamps. status is \"active\", \"degraded\" (the delivery circuit breaker tripped on repeated failures), or \"disabled\".", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
5805
+ const result = await client.get(`/projects/${project_id}/webhooks/subscriptions`);
5806
+ return { content: [{
5807
+ type: "text",
5808
+ text: JSON.stringify(result, null, 2)
5809
+ }] };
5810
+ });
5811
+ registerTool(server, apiClient, "amba_webhooks_get", "Describe a single webhook subscription by id. Returns event_name, target_url, status, attempt metadata, and timestamps (no secret).", {
5812
+ project_id: z.string().describe("The project ID"),
5813
+ subscription_id: z.string().describe("The subscription UUID")
5814
+ }, async ({ project_id, subscription_id }, { client }) => {
5815
+ const result = await client.get(`/projects/${project_id}/webhooks/subscriptions/${subscription_id}`);
5816
+ return { content: [{
5817
+ type: "text",
5818
+ text: JSON.stringify(result, null, 2)
5819
+ }] };
5820
+ });
5821
+ registerTool(server, apiClient, "amba_webhooks_update", "Update a webhook subscription. Any of event_name, target_url, or status may be changed; supply at least one. status accepts only \"active\" or \"disabled\" — \"degraded\" is reserved for the automatic failure circuit breaker and cannot be set manually.", {
5822
+ project_id: z.string().describe("The project ID"),
5823
+ subscription_id: z.string().describe("The subscription UUID"),
5824
+ event_name: z.string().optional().describe("New event name. Must match ^[a-z][a-z0-9_.-]{0,127}$ (case-insensitive)."),
5825
+ target_url: z.string().optional().describe("New HTTPS endpoint. http:// allowed only for localhost."),
5826
+ status: z.enum(["active", "disabled"]).optional().describe("Enable or disable delivery. \"degraded\" cannot be set manually.")
5827
+ }, async ({ project_id, subscription_id, event_name, target_url, status }, { client }) => {
5828
+ const body = {};
5829
+ if (event_name !== void 0) body.event_name = event_name;
5830
+ if (target_url !== void 0) body.target_url = target_url;
5831
+ if (status !== void 0) body.status = status;
5832
+ const result = await client.patch(`/projects/${project_id}/webhooks/subscriptions/${subscription_id}`, body);
5833
+ return { content: [{
5834
+ type: "text",
5835
+ text: JSON.stringify(result, null, 2)
5836
+ }] };
5837
+ });
5838
+ registerTool(server, apiClient, "amba_webhooks_delete", "Delete a webhook subscription. No further events are delivered to its target_url after this.", {
5839
+ project_id: z.string().describe("The project ID"),
5840
+ subscription_id: z.string().describe("The subscription UUID")
5841
+ }, async ({ project_id, subscription_id }, { client }) => {
5842
+ const result = await client.delete(`/projects/${project_id}/webhooks/subscriptions/${subscription_id}`);
5843
+ return { content: [{
5844
+ type: "text",
5845
+ text: JSON.stringify(result, null, 2)
5846
+ }] };
5847
+ });
5848
+ registerTool(server, apiClient, "amba_webhooks_rotate_secret", "Rotate the HMAC signing secret for a subscription. The new secret is returned ONCE — update your receiver config with it. To avoid dropped deliveries, have your receiver accept both the old and new secret for a brief overlap window before retiring the old one.", {
5849
+ project_id: z.string().describe("The project ID"),
5850
+ subscription_id: z.string().describe("The subscription UUID")
5851
+ }, async ({ project_id, subscription_id }, { client }) => {
5852
+ const result = await client.post(`/projects/${project_id}/webhooks/subscriptions/${subscription_id}/rotate-secret`);
5853
+ return { content: [{
5854
+ type: "text",
5855
+ text: JSON.stringify(result, null, 2)
5856
+ }] };
5857
+ });
5858
+ registerTool(server, apiClient, "amba_webhooks_test", "Send a synthetic test delivery to a subscription to verify receiver setup. The test payload is tagged so it does not affect the failure circuit breaker or pollute your real event history. Returns a workflow id; poll amba_webhooks_deliveries_list shortly after for the result. Fails with 409 if the subscription is disabled.", {
5859
+ project_id: z.string().describe("The project ID"),
5860
+ subscription_id: z.string().describe("The subscription UUID")
5861
+ }, async ({ project_id, subscription_id }, { client }) => {
5862
+ const result = await client.post(`/projects/${project_id}/webhooks/subscriptions/${subscription_id}/test`);
5863
+ return { content: [{
5864
+ type: "text",
5865
+ text: JSON.stringify(result, null, 2)
5866
+ }] };
5867
+ });
5868
+ registerTool(server, apiClient, "amba_webhooks_deliveries_list", "List recent webhook delivery attempts (most recent first). Optionally filter by subscription_id and/or status. status is \"pending\", \"delivered\", \"failed\", or \"dlq\" (dead-lettered after exhausting retries). Default page size 50, max 200.", {
5869
+ project_id: z.string().describe("The project ID"),
5870
+ subscription_id: z.string().optional().describe("Filter to deliveries for a single subscription UUID."),
5871
+ status: z.enum([
5872
+ "pending",
5873
+ "delivered",
5874
+ "failed",
5875
+ "dlq"
5876
+ ]).optional().describe("Filter by delivery status."),
5877
+ limit: z.number().optional().describe("Page size (default 50, max 200).")
5878
+ }, async ({ project_id, subscription_id, status, limit }, { client }) => {
5879
+ const query = {};
5880
+ if (subscription_id !== void 0) query.subscription_id = subscription_id;
5881
+ if (status !== void 0) query.status = status;
5882
+ if (limit !== void 0) query.limit = String(limit);
5883
+ const result = await client.get(`/projects/${project_id}/webhooks/deliveries`, query);
5884
+ return { content: [{
5885
+ type: "text",
5886
+ text: JSON.stringify(result, null, 2)
5887
+ }] };
5888
+ });
5889
+ registerTool(server, apiClient, "amba_webhooks_deliveries_get", "Describe a single webhook delivery by id, including the full attempt log, attempt count, and last error.", {
5890
+ project_id: z.string().describe("The project ID"),
5891
+ delivery_id: z.string().describe("The delivery UUID")
5892
+ }, async ({ project_id, delivery_id }, { client }) => {
5893
+ const result = await client.get(`/projects/${project_id}/webhooks/deliveries/${delivery_id}`);
5894
+ return { content: [{
5895
+ type: "text",
5896
+ text: JSON.stringify(result, null, 2)
5897
+ }] };
5898
+ });
5899
+ registerTool(server, apiClient, "amba_webhooks_deliveries_replay", "Re-enqueue a terminated (failed or dead-lettered) delivery, rebuilding the original payload from the source event. The original idempotency key is preserved so receiver-side dedup treats it as a re-delivery, not a new event. Returns 409 if the delivery is still pending, or 410 if the source event has been purged.", {
5900
+ project_id: z.string().describe("The project ID"),
5901
+ delivery_id: z.string().describe("The delivery UUID to replay")
5902
+ }, async ({ project_id, delivery_id }, { client }) => {
5903
+ const result = await client.post(`/projects/${project_id}/webhooks/deliveries/${delivery_id}/replay`);
5904
+ return { content: [{
5905
+ type: "text",
5906
+ text: JSON.stringify(result, null, 2)
5907
+ }] };
5908
+ });
5909
+ }
5910
+ //#endregion
5911
+ //#region src/tools/email.ts
5912
+ function registerTools(server, apiClient) {
5913
+ registerTool(server, apiClient, "amba_email_templates_create", "Create or update a transactional email template (upsert keyed by name — re-creating an existing name overwrites it). Templates are referenced by name when sending. Subject and html_body are required; text_body is an optional plain-text fallback. name must match ^[a-z][a-z0-9_]*$.", {
5914
+ project_id: z.string().describe("The project ID"),
5915
+ name: z.string().describe("Template name. Must match ^[a-z][a-z0-9_]*$."),
5916
+ subject: z.string().describe("Email subject line. Supports template variables."),
5917
+ html_body: z.string().describe("HTML body. Supports template variables."),
5918
+ text_body: z.string().nullable().optional().describe("Optional plain-text fallback body. Pass null to leave unset.")
5919
+ }, async ({ project_id, name, subject, html_body, text_body }, { client }) => {
5920
+ const body = {
5921
+ name,
5922
+ subject,
5923
+ html_body
5924
+ };
5925
+ if (text_body !== void 0) body.text_body = text_body;
5926
+ const result = await client.post(`/projects/${project_id}/email/templates`, body);
5927
+ return { content: [{
5928
+ type: "text",
5929
+ text: JSON.stringify(result, null, 2)
5930
+ }] };
5931
+ });
5932
+ registerTool(server, apiClient, "amba_email_templates_list", "List all transactional email templates for a project, ordered by name.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
5933
+ const result = await client.get(`/projects/${project_id}/email/templates`);
5934
+ return { content: [{
5935
+ type: "text",
5936
+ text: JSON.stringify(result, null, 2)
5937
+ }] };
5938
+ });
5939
+ registerTool(server, apiClient, "amba_email_templates_get", "Get a single transactional email template by name, including its subject, html_body, and text_body.", {
5940
+ project_id: z.string().describe("The project ID"),
5941
+ name: z.string().describe("The template name")
5942
+ }, async ({ project_id, name }, { client }) => {
5943
+ const result = await client.get(`/projects/${project_id}/email/templates/${encodeURIComponent(name)}`);
5944
+ return { content: [{
5945
+ type: "text",
5946
+ text: JSON.stringify(result, null, 2)
5947
+ }] };
5948
+ });
5949
+ registerTool(server, apiClient, "amba_email_templates_update", "Update fields on an existing email template. Supply at least one of subject, html_body, or text_body. For text_body: pass a string to set it, or null to explicitly clear it; omit it to leave it unchanged.", {
5950
+ project_id: z.string().describe("The project ID"),
5951
+ name: z.string().describe("The template name to update"),
5952
+ subject: z.string().optional().describe("New subject line."),
5953
+ html_body: z.string().optional().describe("New HTML body."),
5954
+ text_body: z.string().nullable().optional().describe("New plain-text body; pass null to clear it, omit to leave unchanged.")
5955
+ }, async ({ project_id, name, subject, html_body, text_body }, { client }) => {
5956
+ const body = {};
5957
+ if (subject !== void 0) body.subject = subject;
5958
+ if (html_body !== void 0) body.html_body = html_body;
5959
+ if (text_body !== void 0) body.text_body = text_body;
5960
+ const result = await client.patch(`/projects/${project_id}/email/templates/${encodeURIComponent(name)}`, body);
5961
+ return { content: [{
5962
+ type: "text",
5963
+ text: JSON.stringify(result, null, 2)
5964
+ }] };
5965
+ });
5966
+ registerTool(server, apiClient, "amba_email_templates_delete", "Delete a transactional email template by name. Sends that reference a deleted template will fail with TEMPLATE_NOT_FOUND.", {
5967
+ project_id: z.string().describe("The project ID"),
5968
+ name: z.string().describe("The template name to delete")
5969
+ }, async ({ project_id, name }, { client }) => {
5970
+ const result = await client.delete(`/projects/${project_id}/email/templates/${encodeURIComponent(name)}`);
5971
+ return { content: [{
5972
+ type: "text",
5973
+ text: JSON.stringify(result, null, 2)
5974
+ }] };
5975
+ });
5976
+ registerTool(server, apiClient, "amba_email_suppressions_create", "Add an email address to the suppression list (upsert). Suppressed addresses are skipped on send. reason is \"bounce\", \"complaint\", or \"manual\" (default). Use this to honor unsubscribe or block requests.", {
5977
+ project_id: z.string().describe("The project ID"),
5978
+ email: z.string().describe("Email address to suppress (must contain @)."),
5979
+ reason: z.enum([
5980
+ "bounce",
5981
+ "complaint",
5982
+ "manual"
5983
+ ]).optional().describe("Why the address is suppressed. Defaults to \"manual\".")
5984
+ }, async ({ project_id, email, reason }, { client }) => {
5985
+ const body = { email };
5986
+ if (reason !== void 0) body.reason = reason;
5987
+ const result = await client.post(`/projects/${project_id}/email/suppressions`, body);
5988
+ return { content: [{
5989
+ type: "text",
5990
+ text: JSON.stringify(result, null, 2)
5991
+ }] };
5992
+ });
5993
+ registerTool(server, apiClient, "amba_email_suppressions_list", "List suppressed email addresses (most recent first, up to 500). Optionally filter by reason (\"bounce\", \"complaint\", or \"manual\").", {
5994
+ project_id: z.string().describe("The project ID"),
5995
+ reason: z.enum([
5996
+ "bounce",
5997
+ "complaint",
5998
+ "manual"
5999
+ ]).optional().describe("Filter to a single suppression reason.")
6000
+ }, async ({ project_id, reason }, { client }) => {
6001
+ const query = {};
6002
+ if (reason !== void 0) query.reason = reason;
6003
+ const result = await client.get(`/projects/${project_id}/email/suppressions`, query);
6004
+ return { content: [{
6005
+ type: "text",
6006
+ text: JSON.stringify(result, null, 2)
6007
+ }] };
6008
+ });
6009
+ registerTool(server, apiClient, "amba_email_suppressions_delete", "Remove an email address from the suppression list (operator unblock). Subsequent sends to this address are no longer skipped.", {
6010
+ project_id: z.string().describe("The project ID"),
6011
+ email: z.string().describe("Email address to unsuppress.")
6012
+ }, async ({ project_id, email }, { client }) => {
6013
+ const result = await client.delete(`/projects/${project_id}/email/suppressions/${encodeURIComponent(email)}`);
6014
+ return { content: [{
6015
+ type: "text",
6016
+ text: JSON.stringify(result, null, 2)
6017
+ }] };
6018
+ });
6019
+ registerTool(server, apiClient, "amba_email_send", "Send a transactional email. Recipient is either a user (to.user_id — resolves the user's email) or a raw address (to.email). Send via a template (template + optional data variables) OR ad-hoc to a raw address (subject + html/text). Suppressed recipients are skipped and returned with status \"suppressed\".", {
6020
+ project_id: z.string().describe("The project ID"),
6021
+ to: z.object({
6022
+ user_id: z.string().optional().describe("App user UUID; resolves the user's email server-side."),
6023
+ email: z.string().optional().describe("Raw recipient email address.")
6024
+ }).describe("Recipient. Provide exactly one of user_id or email."),
6025
+ template: z.string().optional().describe("Template name to render. Mutually exclusive with subject/html/text."),
6026
+ data: z.record(z.unknown()).optional().describe("Template variable map (used with `template`)."),
6027
+ subject: z.string().optional().describe("Ad-hoc subject. Requires to.email; mutually exclusive with `template`."),
6028
+ html: z.string().optional().describe("Ad-hoc HTML body (ad-hoc send only)."),
6029
+ text: z.string().optional().describe("Ad-hoc plain-text body (ad-hoc send only).")
6030
+ }, async ({ project_id, to, template, data, subject, html, text }, { client }) => {
6031
+ const body = { to };
6032
+ if (template !== void 0) body.template = template;
6033
+ if (data !== void 0) body.data = data;
6034
+ if (subject !== void 0) body.subject = subject;
6035
+ if (html !== void 0) body.html = html;
6036
+ if (text !== void 0) body.text = text;
6037
+ const result = await client.post(`/projects/${project_id}/email/send`, body);
6038
+ return { content: [{
6039
+ type: "text",
6040
+ text: JSON.stringify(result, null, 2)
6041
+ }] };
6042
+ });
6043
+ registerTool(server, apiClient, "amba_email_deliveries_list", "List recent email delivery records (most recent first). Optionally filter by template name and/or status (\"queued\", \"sent\", \"suppressed\", \"failed\"). Default page size 50, max 200.", {
6044
+ project_id: z.string().describe("The project ID"),
6045
+ template: z.string().optional().describe("Filter to a single template name."),
6046
+ status: z.enum([
6047
+ "queued",
6048
+ "sent",
6049
+ "suppressed",
6050
+ "failed"
6051
+ ]).optional().describe("Filter by delivery status."),
6052
+ limit: z.number().optional().describe("Page size (default 50, max 200).")
6053
+ }, async ({ project_id, template, status, limit }, { client }) => {
6054
+ const query = {};
6055
+ if (template !== void 0) query.template = template;
6056
+ if (status !== void 0) query.status = status;
6057
+ if (limit !== void 0) query.limit = String(limit);
6058
+ const result = await client.get(`/projects/${project_id}/email/deliveries`, query);
6059
+ return { content: [{
6060
+ type: "text",
6061
+ text: JSON.stringify(result, null, 2)
6062
+ }] };
6063
+ });
6064
+ registerTool(server, apiClient, "amba_email_deliveries_get", "Describe a single email delivery by id, including recipient, template, status, and last error if it failed.", {
6065
+ project_id: z.string().describe("The project ID"),
6066
+ delivery_id: z.string().describe("The delivery UUID")
6067
+ }, async ({ project_id, delivery_id }, { client }) => {
6068
+ const result = await client.get(`/projects/${project_id}/email/deliveries/${delivery_id}`);
6069
+ return { content: [{
6070
+ type: "text",
6071
+ text: JSON.stringify(result, null, 2)
6072
+ }] };
6073
+ });
4790
6074
  }
4791
6075
  //#endregion
4792
6076
  //#region src/resources/amba-setup-identity.ts
@@ -5586,6 +6870,7 @@ Real-money purchases need a billing integration configured separately — see \`
5586
6870
  | \`amba_currencies_update\` | Edit (rename, change caps, change auto-recharge). | \`{ project_id, currency_id, max_balance: 10000 }\` |
5587
6871
  | \`amba_currencies_delete\` | Delete a currency (irreversible — users lose their balance). | \`{ project_id, currency_id }\` |
5588
6872
  | \`amba_currencies_grant\` | Grant currency to a specific user. | \`{ project_id, app_user_id, currency_code: "gems", amount: 100, reason: "welcome_bonus" }\` |
6873
+ | \`amba_currencies_spend\` | Debit currency from a specific user. Atomic; returns INSUFFICIENT_FUNDS if balance is too low (no partial debit). | \`{ project_id, app_user_id, currency_code: "gems", amount: 5, reason: "ai_generation" }\` |
5589
6874
  | \`amba_currencies_get_transactions\` | Per-user transaction ledger. | \`{ project_id, user_id, currency_code: "gems", limit: 100 }\` |
5590
6875
 
5591
6876
  #### Currency grant rules (auto-grants)
@@ -5871,6 +7156,9 @@ Mostly SDK-side — admin tools are for moderation + stats.
5871
7156
 
5872
7157
  | Tool | Purpose | Example args |
5873
7158
  | --- | --- | --- |
7159
+ | \`amba_messaging_create_conversation\` | Create a conversation server-side (zero or more initial participants — for matchmaker-built circles). | \`{ project_id, type: "group", name: "Circle" }\` |
7160
+ | \`amba_messaging_add_participant\` | Add a user to a conversation (idempotent). The matchmaker primitive for growing membership over time. | \`{ project_id, conversation_id, user_id }\` |
7161
+ | \`amba_messaging_remove_participant\` | Remove a user from a conversation (idempotent). | \`{ project_id, conversation_id, user_id }\` |
5874
7162
  | \`amba_messaging_list_conversations\` | List conversations (admin / moderation view). | \`{ project_id, limit: 50 }\` |
5875
7163
  | \`amba_messaging_list_messages\` | List messages in a conversation. | \`{ project_id, conversation_id, limit: 100 }\` |
5876
7164
  | \`amba_messaging_delete_message\` | Hard-delete a message (moderation). | \`{ project_id, conversation_id, message_id }\` |
@@ -6092,25 +7380,28 @@ If gamification, economy, and social are the playable surface, **infrastructure
6092
7380
 
6093
7381
  A collection is a schema-first table inside the project's isolated tenant database. You describe the columns, the server creates the table and any indexes. Rows are scoped to the signed-in \`app_user\` automatically (server-enforced auto row-level isolation) for SDK clients — admin tools bypass this.
6094
7382
 
7383
+ Admin tools authenticate the developer/agent (pass \`pat\` or send it as the inbound Bearer) and take \`project_id\`. Client tools authenticate an end-user and take \`api_key\` (+ \`session_token\`) — NOT \`project_id\` and NOT a \`pat\`. Every row tool names the collection with \`name\`, never \`collection\`.
7384
+
6095
7385
  | Tool | Purpose | Example args |
6096
7386
  | --- | --- | --- |
6097
- | \`amba_collections_create\` | Create a typed collection. | \`{ 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 }] }\` |
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 }\` |
6098
7388
  | \`amba_collections_list\` | List collections in this project. | \`{ project_id }\` |
6099
- | \`amba_collections_get\` | Read one collection's schema. | \`{ project_id, collection_name: "todos" }\` |
6100
- | \`amba_collections_alter\` | Add / drop columns, add / drop indexes. | \`{ project_id, collection_name: "todos", add_columns: [{ name: "priority", type: "int", nullable: true }] }\` |
6101
- | \`amba_collections_delete\` | Drop the table (destructive). | \`{ project_id, collection_name }\` |
6102
- | \`amba_admin_insert_row\` | Insert a row as the developer (bypasses user-scope). | \`{ project_id, collection: "todos", row: { title: "Sample", done: false } }\` |
6103
- | \`amba_admin_list_rows\` | Read rows as the developer. | \`{ project_id, collection: "todos", limit: 100 }\` |
6104
- | \`amba_client_insert_row\` | Insert as an end-user. Requires \`api_key\` + \`session_token\`. | \`{ project_id, api_key, session_token, collection: "todos", row: {...} }\` |
6105
- | \`amba_client_list_rows\` | Read as an end-user (auto user-scoped). | \`{ project_id, api_key, session_token, collection: "todos" }\` |
6106
- | \`amba_client_get_row\` | Get one row by id (end-user). | \`{ project_id, api_key, session_token, collection, row_id }\` |
6107
- | \`amba_client_update_row\` | Update one row (end-user). | \`{ project_id, api_key, session_token, collection, row_id, patch: {...} }\` |
6108
- | \`amba_client_delete_row\` | Delete one row (end-user). | \`{ project_id, api_key, session_token, collection, row_id }\` |
6109
- | \`amba_client_count_rows\` | Count rows matching a filter. | \`{ project_id, api_key, session_token, collection, filter: {...} }\` |
6110
- | \`amba_client_find_rows\` | Filter / sort / paginate rows. | \`{ project_id, api_key, session_token, collection, filter: {...}, order_by: [...], limit: 50 }\` |
6111
- | \`amba_client_find_nearest_rows\` | Vector-similarity search (rows with a \`vector(<dim>)\` column). | \`{ project_id, api_key, session_token, collection, vector_column: "embedding", query_vector: [...], k: 10 }\` |
6112
-
6113
- Column types: \`text\`, \`int\`, \`bigint\`, \`float\`, \`boolean\`, \`timestamptz\`, \`date\`, \`json\`, \`jsonb\`, \`uuid\`, \`vector(<dim>)\` (e.g. \`vector(1536)\` for OpenAI embeddings).
7389
+ | \`amba_collections_get\` | Read one collection's schema. | \`{ project_id, name: "todos" }\` |
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 } }\` |
7391
+ | \`amba_collections_delete\` | Drop the table (destructive). \`confirm\` must equal the collection name. | \`{ project_id, name: "todos", confirm: "todos" }\` |
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 } }\` |
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" }\` |
7394
+ | \`amba_admin_list_rows\` | Read rows as the developer. | \`{ project_id, name: "todos", limit: 100 }\` |
7395
+ | \`amba_client_insert_row\` | Insert as an end-user. Requires \`api_key\` (+ \`session_token\`). | \`{ api_key, session_token, name: "todos", row: {...} }\` |
7396
+ | \`amba_client_list_rows\` | Read as an end-user (auto user-scoped). | \`{ api_key, session_token, name: "todos" }\` |
7397
+ | \`amba_client_get_row\` | Get one row by id (end-user). | \`{ api_key, session_token, name: "todos", id }\` |
7398
+ | \`amba_client_update_row\` | Update one row by id (end-user). Fields go in \`set\`. Omit \`id\` + pass \`where\` for a bulk update. | \`{ api_key, session_token, name: "todos", id, set: {...} }\` |
7399
+ | \`amba_client_delete_row\` | Soft-delete one row by id (end-user). | \`{ api_key, session_token, name: "todos", id }\` |
7400
+ | \`amba_client_count_rows\` | Count rows matching an optional \`where\`. | \`{ api_key, session_token, name: "todos", where: {...} }\` |
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 }\` |
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 }\` |
7403
+
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.
6114
7405
 
6115
7406
  ### Functions (serverless code)
6116
7407
 
@@ -6130,16 +7421,23 @@ Run user code in a sandbox triggered by HTTP, cron, or webhook. The function get
6130
7421
 
6131
7422
  ### AI prompts
6132
7423
 
6133
- 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\`.
6134
7429
 
6135
7430
  | Tool | Purpose | Example args |
6136
7431
  | --- | --- | --- |
6137
- | \`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 }\` |
6138
7436
  | \`amba_ai_prompts_list\` | List prompts. | \`{ project_id }\` |
6139
- | \`amba_ai_prompts_get\` | Read one prompt. | \`{ project_id, key }\` |
6140
- | \`amba_ai_prompts_update\` | Edit a prompt. | \`{ project_id, key, system: "..." }\` |
6141
- | \`amba_ai_prompts_invoke\` | Invoke a prompt server-side (admin testing). | \`{ project_id, key, variables: { text: "..." } }\` |
6142
- | \`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 }\` |
6143
7441
 
6144
7442
  ### Analytics + events + sessions
6145
7443
 
@@ -6155,9 +7453,11 @@ Managed LLM templates: stored prompt with model + system message + variables, ca
6155
7453
 
6156
7454
  ### Secrets + configs + integrations
6157
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
+
6158
7458
  | Tool | Purpose | Example args |
6159
7459
  | --- | --- | --- |
6160
- | \`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_..." }\` |
6161
7461
  | \`amba_secrets_get\` | Read a secret (returns \`"<redacted>"\` unless explicitly requested). | \`{ project_id, name }\` |
6162
7462
  | \`amba_secrets_list\` | List secret names. | \`{ project_id }\` |
6163
7463
  | \`amba_secrets_delete\` | Delete. | \`{ project_id, name }\` |
@@ -6216,9 +7516,9 @@ const newTodo = await Amba.collections.insert('todos', { title: 'Ship the app',
6216
7516
  await Amba.collections.update('todos', newTodo.id, { done: true });
6217
7517
  await Amba.collections.delete('todos', newTodo.id);
6218
7518
 
6219
- // AI — call a managed prompt
7519
+ // AI — call a managed prompt (prompt_slug names the registered prompt)
6220
7520
  const response = await Amba.ai.anthropic.messages.create({
6221
- prompt_key: 'summarize',
7521
+ prompt_slug: 'summarize',
6222
7522
  variables: { text: 'A long article about backend services …' },
6223
7523
  });
6224
7524
 
@@ -6286,8 +7586,7 @@ let showBeta = try await Amba.flags.get(name: "beta_feature")
6286
7586
  try await Amba.events.track("app_opened", properties: ["source": "deep_link"])
6287
7587
 
6288
7588
  let reply = try await Amba.ai.anthropic.messages.create(
6289
- promptKey: "summarize",
6290
- variables: ["text": "A long article..."]
7589
+ request: AiMessageRequest(promptSlug: "summarize", variables: ["text": "A long article..."])
6291
7590
  )
6292
7591
  \`\`\`
6293
7592
 
@@ -6344,7 +7643,7 @@ Batch.
6344
7643
  - [ ] Resend (transactional email)
6345
7644
  - [ ] Stripe (web payments / subscriptions)
6346
7645
  - [ ] Mixpanel / PostHog / Segment (analytics forwarding)
6347
- - [ ] 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\`)
6348
7647
 
6349
7648
  6. **Feature flags:** seed any starter flags?
6350
7649
  - Yes — wire \`beta_feature\` (off by default) so I can ship the wiring before the feature exists
@@ -6359,7 +7658,7 @@ Batch.
6359
7658
  1. Before creating:
6360
7659
  - \`amba_collections_list\` — match on \`name\`. Collisions: never silently recreate (data loss). Offer \`amba_collections_alter\` to add new columns instead.
6361
7660
  - \`amba_functions_list\` — match on \`name\`. Collisions: ask to redeploy (with the new source) or skip.
6362
- - \`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.)
6363
7662
  - \`amba_integrations_list\` — match on \`provider\`. Same.
6364
7663
  - \`amba_configs_list\` — match on \`key\`. Same.
6365
7664
 
@@ -6541,6 +7840,8 @@ const TOOL_CATEGORY = {
6541
7840
  amba_developer_rotate_pat: "identity",
6542
7841
  amba_users_list: "identity",
6543
7842
  amba_list_users: "identity",
7843
+ amba_users_create: "identity",
7844
+ amba_create_user: "identity",
6544
7845
  amba_users_get: "identity",
6545
7846
  amba_users_delete: "identity",
6546
7847
  amba_users_export: "identity",
@@ -6589,6 +7890,7 @@ const TOOL_CATEGORY = {
6589
7890
  amba_onboarding_get_stats: "engagement",
6590
7891
  amba_get_onboarding_stats: "engagement",
6591
7892
  amba_content_libraries_create: "engagement",
7893
+ amba_content_libraries_delete: "engagement",
6592
7894
  amba_create_content_library: "engagement",
6593
7895
  amba_content_list_libraries: "engagement",
6594
7896
  amba_content_items_add: "engagement",
@@ -6623,6 +7925,8 @@ const TOOL_CATEGORY = {
6623
7925
  amba_list_xp_rules: "gamification",
6624
7926
  amba_xp_update_rule: "gamification",
6625
7927
  amba_xp_delete_rule: "gamification",
7928
+ amba_xp_grant: "gamification",
7929
+ amba_xp_deduct: "gamification",
6626
7930
  amba_xp_list_users: "gamification",
6627
7931
  amba_users_get_xp: "gamification",
6628
7932
  amba_get_user_xp: "gamification",
@@ -6636,11 +7940,14 @@ const TOOL_CATEGORY = {
6636
7940
  amba_update_achievement: "gamification",
6637
7941
  amba_achievements_delete: "gamification",
6638
7942
  amba_delete_achievement: "gamification",
7943
+ amba_achievements_grant: "gamification",
7944
+ amba_achievements_revoke: "gamification",
6639
7945
  amba_streaks_create: "gamification",
6640
7946
  amba_create_streak: "gamification",
6641
7947
  amba_streaks_list: "gamification",
6642
7948
  amba_streaks_update: "gamification",
6643
7949
  amba_streaks_delete: "gamification",
7950
+ amba_streaks_reset: "gamification",
6644
7951
  amba_leaderboards_create: "gamification",
6645
7952
  amba_create_leaderboard: "gamification",
6646
7953
  amba_leaderboards_list: "gamification",
@@ -6653,6 +7960,14 @@ const TOOL_CATEGORY = {
6653
7960
  amba_update_leaderboard: "gamification",
6654
7961
  amba_leaderboards_delete: "gamification",
6655
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",
6656
7971
  amba_challenges_create: "gamification",
6657
7972
  amba_create_challenge: "gamification",
6658
7973
  amba_challenges_list: "gamification",
@@ -6675,6 +7990,7 @@ const TOOL_CATEGORY = {
6675
7990
  amba_delete_currency: "economy",
6676
7991
  amba_currencies_grant: "economy",
6677
7992
  amba_grant_currency: "economy",
7993
+ amba_currencies_spend: "economy",
6678
7994
  amba_currencies_get_transactions: "economy",
6679
7995
  amba_get_currency_transactions: "economy",
6680
7996
  amba_currency_grant_rules_create: "economy",
@@ -6709,6 +8025,7 @@ const TOOL_CATEGORY = {
6709
8025
  amba_stores_patch_listing: "economy",
6710
8026
  amba_stores_delete_listing: "economy",
6711
8027
  amba_inventory_grant_item: "economy",
8028
+ amba_inventory_revoke_item: "economy",
6712
8029
  amba_grant_item: "economy",
6713
8030
  amba_users_get_inventory: "economy",
6714
8031
  amba_get_user_inventory: "economy",
@@ -6732,6 +8049,9 @@ const TOOL_CATEGORY = {
6732
8049
  amba_update_group_member: "social",
6733
8050
  amba_groups_remove_member: "social",
6734
8051
  amba_remove_group_member: "social",
8052
+ amba_messaging_create_conversation: "social",
8053
+ amba_messaging_add_participant: "social",
8054
+ amba_messaging_remove_participant: "social",
6735
8055
  amba_messaging_list_conversations: "social",
6736
8056
  amba_messaging_list_messages: "social",
6737
8057
  amba_messaging_delete_message: "social",
@@ -6770,6 +8090,20 @@ const TOOL_CATEGORY = {
6770
8090
  amba_get_analytics: "analytics",
6771
8091
  amba_sessions_list: "analytics",
6772
8092
  amba_sessions_analytics: "analytics",
8093
+ amba_funnels_list: "analytics",
8094
+ amba_funnels_create: "analytics",
8095
+ amba_funnels_get: "analytics",
8096
+ amba_funnels_update: "analytics",
8097
+ amba_funnels_delete: "analytics",
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",
6773
8107
  amba_projects_create: "infrastructure",
6774
8108
  amba_create_project: "infrastructure",
6775
8109
  amba_projects_list: "infrastructure",
@@ -6796,6 +8130,7 @@ const TOOL_CATEGORY = {
6796
8130
  amba_delete_collection: "infrastructure",
6797
8131
  amba_admin_insert_row: "infrastructure",
6798
8132
  amba_admin_list_rows: "infrastructure",
8133
+ amba_admin_aggregate_rows: "infrastructure",
6799
8134
  amba_client_insert_row: "infrastructure",
6800
8135
  amba_client_list_rows: "infrastructure",
6801
8136
  amba_client_get_row: "infrastructure",
@@ -6836,6 +8171,10 @@ const TOOL_CATEGORY = {
6836
8171
  amba_remove_site_domain: "infrastructure",
6837
8172
  amba_sites_list_domains: "infrastructure",
6838
8173
  amba_list_site_domains: "infrastructure",
8174
+ amba_domains_search: "infrastructure",
8175
+ amba_domains_check: "infrastructure",
8176
+ amba_domains_purchase: "infrastructure",
8177
+ amba_domains_list: "infrastructure",
6839
8178
  amba_media_upload: "infrastructure",
6840
8179
  amba_upload_media: "infrastructure",
6841
8180
  amba_media_list: "infrastructure",
@@ -6867,6 +8206,9 @@ const TOOL_CATEGORY = {
6867
8206
  amba_integrations_patch: "infrastructure",
6868
8207
  amba_integrations_test: "infrastructure",
6869
8208
  amba_test_integration: "infrastructure",
8209
+ amba_ai_providers_set: "infrastructure",
8210
+ amba_ai_providers_list: "infrastructure",
8211
+ amba_ai_providers_delete: "infrastructure",
6870
8212
  amba_ai_prompts_create: "infrastructure",
6871
8213
  amba_create_ai_prompt: "infrastructure",
6872
8214
  amba_ai_prompts_list: "infrastructure",
@@ -6886,7 +8228,34 @@ const TOOL_CATEGORY = {
6886
8228
  amba_billing_set_ceiling: "infrastructure",
6887
8229
  amba_projects_invite_member: "infrastructure",
6888
8230
  amba_projects_list_members: "infrastructure",
6889
- amba_projects_remove_member: "infrastructure"
8231
+ amba_projects_remove_member: "infrastructure",
8232
+ amba_projects_transfer_owner: "infrastructure",
8233
+ amba_webhooks_create: "infrastructure",
8234
+ amba_webhooks_list: "infrastructure",
8235
+ amba_webhooks_get: "infrastructure",
8236
+ amba_webhooks_update: "infrastructure",
8237
+ amba_webhooks_delete: "infrastructure",
8238
+ amba_webhooks_rotate_secret: "infrastructure",
8239
+ amba_webhooks_test: "infrastructure",
8240
+ amba_webhooks_deliveries_list: "infrastructure",
8241
+ amba_webhooks_deliveries_get: "infrastructure",
8242
+ amba_webhooks_deliveries_replay: "infrastructure",
8243
+ amba_email_templates_create: "engagement",
8244
+ amba_email_templates_list: "engagement",
8245
+ amba_email_templates_get: "engagement",
8246
+ amba_email_templates_update: "engagement",
8247
+ amba_email_templates_delete: "engagement",
8248
+ amba_email_suppressions_create: "engagement",
8249
+ amba_email_suppressions_list: "engagement",
8250
+ amba_email_suppressions_delete: "engagement",
8251
+ amba_email_send: "engagement",
8252
+ amba_email_deliveries_list: "engagement",
8253
+ amba_email_deliveries_get: "engagement",
8254
+ amba_admin_insert_rows: "infrastructure",
8255
+ amba_remote_configs_create: "infrastructure",
8256
+ amba_remote_configs_list: "infrastructure",
8257
+ amba_remote_configs_update: "infrastructure",
8258
+ amba_remote_configs_delete: "infrastructure"
6890
8259
  };
6891
8260
  /**
6892
8261
  * Look up the canonical category for an MCP tool name. Accepts both
@@ -6906,7 +8275,15 @@ function getToolCategory(toolName) {
6906
8275
  * called against it once.
6907
8276
  */
6908
8277
  function registerAllTools(server, apiClient) {
6909
- registerTools$7(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);
8283
+ registerTools$29(server, apiClient);
8284
+ registerTools$28(server, apiClient);
8285
+ registerTools$27(server, apiClient);
8286
+ registerTools$26(server, apiClient);
6910
8287
  registerTools$25(server, apiClient);
6911
8288
  registerTools$24(server, apiClient);
6912
8289
  registerTools$23(server, apiClient);
@@ -6919,12 +8296,12 @@ function registerAllTools(server, apiClient) {
6919
8296
  registerTools$16(server, apiClient);
6920
8297
  registerTools$15(server, apiClient);
6921
8298
  registerTools$14(server, apiClient);
6922
- registerTools$13(server, apiClient);
6923
8299
  registerTools$12(server, apiClient);
6924
8300
  registerTools$11(server, apiClient);
6925
8301
  registerTools$10(server, apiClient);
6926
8302
  registerTools$9(server, apiClient);
6927
8303
  registerTools$8(server, apiClient);
8304
+ registerTools$7(server, apiClient);
6928
8305
  registerTools$6(server, apiClient);
6929
8306
  registerTools$5(server, apiClient);
6930
8307
  registerTools$4(server, apiClient);