agentful 0.4.6 → 0.4.8

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.cjs CHANGED
@@ -33,9 +33,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
  mod
34
34
  ));
35
35
 
36
- // node_modules/commander/lib/error.js
36
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/error.js
37
37
  var require_error = __commonJS({
38
- "node_modules/commander/lib/error.js"(exports2) {
38
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/error.js"(exports2) {
39
39
  "use strict";
40
40
  var CommanderError2 = class extends Error {
41
41
  /**
@@ -69,9 +69,9 @@ var require_error = __commonJS({
69
69
  }
70
70
  });
71
71
 
72
- // node_modules/commander/lib/argument.js
72
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/argument.js
73
73
  var require_argument = __commonJS({
74
- "node_modules/commander/lib/argument.js"(exports2) {
74
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/argument.js"(exports2) {
75
75
  "use strict";
76
76
  var { InvalidArgumentError: InvalidArgumentError2 } = require_error();
77
77
  var Argument2 = class {
@@ -197,9 +197,9 @@ var require_argument = __commonJS({
197
197
  }
198
198
  });
199
199
 
200
- // node_modules/commander/lib/help.js
200
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/help.js
201
201
  var require_help = __commonJS({
202
- "node_modules/commander/lib/help.js"(exports2) {
202
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/help.js"(exports2) {
203
203
  "use strict";
204
204
  var { humanReadableArgName } = require_argument();
205
205
  var Help2 = class {
@@ -612,9 +612,9 @@ var require_help = __commonJS({
612
612
  }
613
613
  });
614
614
 
615
- // node_modules/commander/lib/option.js
615
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/option.js
616
616
  var require_option = __commonJS({
617
- "node_modules/commander/lib/option.js"(exports2) {
617
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/option.js"(exports2) {
618
618
  "use strict";
619
619
  var { InvalidArgumentError: InvalidArgumentError2 } = require_error();
620
620
  var Option2 = class {
@@ -885,9 +885,9 @@ var require_option = __commonJS({
885
885
  }
886
886
  });
887
887
 
888
- // node_modules/commander/lib/suggestSimilar.js
888
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/suggestSimilar.js
889
889
  var require_suggestSimilar = __commonJS({
890
- "node_modules/commander/lib/suggestSimilar.js"(exports2) {
890
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/suggestSimilar.js"(exports2) {
891
891
  "use strict";
892
892
  var maxDistance = 3;
893
893
  function editDistance(a, b) {
@@ -966,9 +966,9 @@ var require_suggestSimilar = __commonJS({
966
966
  }
967
967
  });
968
968
 
969
- // node_modules/commander/lib/command.js
969
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/command.js
970
970
  var require_command = __commonJS({
971
- "node_modules/commander/lib/command.js"(exports2) {
971
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/lib/command.js"(exports2) {
972
972
  "use strict";
973
973
  var EventEmitter = require("events").EventEmitter;
974
974
  var childProcess = require("child_process");
@@ -3010,9 +3010,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
3010
3010
  }
3011
3011
  });
3012
3012
 
3013
- // node_modules/commander/index.js
3013
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/index.js
3014
3014
  var require_commander = __commonJS({
3015
- "node_modules/commander/index.js"(exports2) {
3015
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/index.js"(exports2) {
3016
3016
  "use strict";
3017
3017
  var { Argument: Argument2 } = require_argument();
3018
3018
  var { Command: Command2 } = require_command();
@@ -3044,7 +3044,7 @@ var VERSION, brand, useColor, wrap, paint, tuiDesign, sym, ui;
3044
3044
  var init_branding = __esm({
3045
3045
  "src/branding.ts"() {
3046
3046
  "use strict";
3047
- VERSION = "0.4.6" ? "0.4.6" : null.version;
3047
+ VERSION = "0.4.8" ? "0.4.8" : null.version;
3048
3048
  brand = {
3049
3049
  name: "agentful",
3050
3050
  // the command users type
@@ -3103,9 +3103,9 @@ var init_branding = __esm({
3103
3103
  }
3104
3104
  });
3105
3105
 
3106
- // node_modules/ignore/index.js
3106
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/ignore/index.js
3107
3107
  var require_ignore = __commonJS({
3108
- "node_modules/ignore/index.js"(exports2, module2) {
3108
+ "../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/ignore/index.js"(exports2, module2) {
3109
3109
  "use strict";
3110
3110
  function makeArray(subject) {
3111
3111
  return Array.isArray(subject) ? subject : [subject];
@@ -3824,7 +3824,7 @@ var init_plugin = __esm({
3824
3824
  }
3825
3825
  });
3826
3826
 
3827
- // node_modules/commander/esm.mjs
3827
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/commander/esm.mjs
3828
3828
  var import_index = __toESM(require_commander(), 1);
3829
3829
  var {
3830
3830
  program,
@@ -4785,7 +4785,7 @@ init_branding();
4785
4785
  var import_node_fs4 = require("fs");
4786
4786
  var import_node_path6 = require("path");
4787
4787
 
4788
- // node_modules/fflate/esm/index.mjs
4788
+ // ../../../../../../../../Users/gn/Labor/mainmvp/cli/node_modules/fflate/esm/index.mjs
4789
4789
  var import_module = require("module");
4790
4790
  var require2 = (0, import_module.createRequire)("/");
4791
4791
  var _a;
@@ -5597,18 +5597,28 @@ function detectDistDir(rootDir, override) {
5597
5597
  }
5598
5598
 
5599
5599
  // src/lib/preflight.ts
5600
- var import_node_fs5 = require("fs");
5601
- var import_node_path7 = require("path");
5600
+ var import_node_fs6 = require("fs");
5601
+ var import_node_path8 = require("path");
5602
5602
 
5603
5603
  // ../platform-contract/contract.json
5604
5604
  var contract_default = {
5605
5605
  $comment: "Single source of truth for the platform publish contract (PRD AP6a, planned/cli-push-to-workspace-transition-findings-and-prd-2026-08-17.md). Consumers: cli/src/lib/preflight.ts renders directly from this file; build engine (infrastructure/lambda/build-engine/handler.ts) and executor (opencode-lambda/executor/runtime/opencode_runner.py) are parity-checked against it by ./check-parity.py until AP4/AP5 move them onto it; AP0 instructions/skills render from it later. Bump schema_version on breaking shape changes. See README.md.",
5606
5606
  schema_version: 1,
5607
- contract_statement: "This platform publishes static files. Your app has no server process of its own \u2014 server-side logic runs as Managed Server Actions (short request/response functions) or on an API of your own that you connect. You cannot set build-time env vars or secrets: the workspace build injects only a fixed allowlist of public client values, and the `agentful push` build injects none.",
5607
+ contract_statement: "This platform publishes static files. Your app has no server process of its own \u2014 server-side logic runs as Managed Server Actions (short request/response functions) or on an API of your own that you connect. You cannot set build-time env vars or secrets: every cloud build injects the same fixed allowlist of public client values, and nothing else.",
5608
5608
  publish: {
5609
5609
  server_process: false,
5610
5610
  build_env_vars: false,
5611
- $comment_build_env: "false = nobody can SET a build variable or build secret. Two build paths exist and they differ: the workspace executor injects a fixed allowlist of PUBLIC client values (VITE_PROJECT_ID/PUBLIC_PROJECT_ID, and for a BYO Supabase database its URL + publishable key as VITE_/PUBLIC_/NEXT_PUBLIC_); the build engine behind `agentful push` injects nothing beyond NODE_ENV. Code that must build on both paths keeps public values in an ordinary source constant.",
5611
+ $comment_build_env: "false = nobody can SET a build variable or build secret. Two build paths exist \u2014 the workspace executor (end of an agent turn) and the build engine (the workspace's Build button and `agentful push`) \u2014 and both inject the SAME fixed allowlist of PUBLIC client values, listed under build_env. The values come from one function on both paths (aws-services/workspace-manager/client_config.py); check-parity.py (check 13) pins the names. Until 2026-09 the build engine injected nothing, so an app that read import.meta.env.VITE_SUPABASE_URL worked after the agent's turn and lost its database on the next Build click \u2014 without a build error. A public value outside the allowlist still belongs in an ordinary source constant.",
5612
+ build_env: {
5613
+ $comment: "What a cloud build defines, and nothing else. `injected` = suffix x prefixes; `when: byo_supabase` means only while the project's Backend tab has an own Supabase database connected (URL + publishable key; a secret key stored in that slot is refused, never shipped). A read of <public prefix><injected suffix> that the build does NOT define fails the build (422 undefined_build_env): undefined there is a broken app, not a default. Any other read of a public-prefixed name that nothing defines is a warning, because `import.meta.env.VITE_API_BASE ?? '/api'` is a legitimate pattern. `bundler_builtins` are set by the framework itself and are never reported.",
5614
+ public_prefixes: ["VITE_", "PUBLIC_", "NEXT_PUBLIC_"],
5615
+ injected: [
5616
+ { suffix: "PROJECT_ID", prefixes: ["VITE_", "PUBLIC_"], when: "always" },
5617
+ { suffix: "SUPABASE_URL", prefixes: ["VITE_", "PUBLIC_", "NEXT_PUBLIC_"], when: "byo_supabase" },
5618
+ { suffix: "SUPABASE_ANON_KEY", prefixes: ["VITE_", "PUBLIC_", "NEXT_PUBLIC_"], when: "byo_supabase" }
5619
+ ],
5620
+ bundler_builtins: ["PUBLIC_URL"]
5621
+ },
5612
5622
  published_prefix: "dist/",
5613
5623
  canonical_output_check: "root_index_html",
5614
5624
  $comment_output: "Canonical end-to-end contract (AP4): a root-level index.html inside the framework's output dir. Deliberately stricter than the executor's historical index.htm/200.html acceptance \u2014 both builders converge on THIS, not on each other (deployment gate and publish self-heal require dist/index.html)."
@@ -5628,6 +5638,8 @@ var contract_default = {
5628
5638
  "Runtime secrets that never reach the browser: await ctx.secrets.get('server.<name>')",
5629
5639
  "ctx.fetch to any HTTPS API",
5630
5640
  "ctx.user: the JWT-verified end-user {id, email, pid}, or null",
5641
+ "Webhook signature verification by the platform host: await ctx.webhooks.verifyStripe('server.<name>') and await ctx.webhooks.verifyGitHub('server.<name>') return {valid, reason}; await ctx.verifyHmac({secret, message, signature, algorithm, encoding}) returns true or false for other providers that sign with an HMAC (sha256, sha512 or sha1; hex or base64). The signing secret is looked up by name and never enters the action",
5642
+ "ctx.rawBody: the request body exactly as received, as text \u2014 the bytes a sender signed, and the way to read a form-encoded body (new URLSearchParams(ctx.rawBody)). ctx.body stays the parsed JSON, and {} when the body is not JSON",
5631
5643
  "ctx.data: CRUD on the managed database with admin rights (a collection's access_rule does not apply here); list returns at most 100 documents, without filter or cursor, and there is no conditional write",
5632
5644
  "Chaining of the project's own actions, up to the self-call depth limit"
5633
5645
  ],
@@ -5640,12 +5652,12 @@ var contract_default = {
5640
5652
  {
5641
5653
  what: "npm packages, require/import, Node core modules, process/env, crypto, setTimeout/setInterval",
5642
5654
  why: "The sandbox has no module system, no crypto primitive and no timers",
5643
- workaround: "Call an HTTPS API with ctx.fetch; do library-bound work (PDF, image processing) in the browser"
5655
+ workaround: "Call an HTTPS API with ctx.fetch; do library-bound work (PDF, image processing) in the browser; verify webhook signatures with ctx.webhooks / ctx.verifyHmac, which run in the platform host"
5644
5656
  },
5645
5657
  {
5646
- what: "Webhook signature verification; form-encoded or raw request bodies",
5647
- why: "No crypto and no raw body; a body that is not valid JSON silently arrives as {}",
5648
- workaround: "Fetch the event from the provider by its id and use only the fetched data (this proves the event, not the sender), or put a signature-verifying receiver in front of the action"
5658
+ what: "Webhook signatures that are not an HMAC keyed with the stored secret as text: asymmetric signatures (Ed25519, ECDSA, RSA) and Svix / Standard Webhooks secrets (svix-signature or webhook-signature header; the key is the base64-decoded part after whsec_ \u2014 Stripe's whsec_ secret is NOT one of these, Stripe uses it as text); binary request bodies",
5659
+ why: "The host computes HMACs only and uses the stored secret as the key exactly as it was typed; a body that arrives base64-encoded (image, font and PDF content types) leaves ctx.rawBody null, and a check on it fails closed",
5660
+ workaround: "Where the provider has an API call that returns the event or payment by its id, fetch it and use only the fetched data (this proves the event, not the sender); otherwise put a signature-verifying receiver in front of the action"
5649
5661
  },
5650
5662
  {
5651
5663
  what: "WebSockets, streaming/SSE, long-running work",
@@ -5686,7 +5698,7 @@ var contract_default = {
5686
5698
  offers: [
5687
5699
  "Same-origin proxy: /api/* is forwarded to the owner's HTTPS endpoint; method, body and headers are preserved and X-Forwarded-Host is set",
5688
5700
  "No CORS setup for the app",
5689
- "Everything that endpoint can do itself: its own runtime, libraries, schedules, webhook signature verification"
5701
+ "Everything that endpoint can do itself: its own runtime, libraries, schedules, its own cryptography"
5690
5702
  ],
5691
5703
  not_offered: [
5692
5704
  {
@@ -5806,7 +5818,7 @@ var contract_default = {
5806
5818
  build_attempt_schema: {
5807
5819
  file: "deployments/latest-build.json",
5808
5820
  status_values: ["running", "success", "failed"],
5809
- phase_codes: ["unsupported_project_type", "install", "build", "missing_output", "upload"],
5821
+ phase_codes: ["unsupported_project_type", "client_env", "install", "build", "missing_output", "upload"],
5810
5822
  fields: {
5811
5823
  schema_version: "int \u2014 copy of this contract's schema_version",
5812
5824
  build_id: "string \u2014 unique per attempt",
@@ -5826,6 +5838,154 @@ var contract_default = {
5826
5838
  }
5827
5839
  };
5828
5840
 
5841
+ // ../platform-contract/buildEnv.ts
5842
+ var import_node_fs5 = require("fs");
5843
+ var import_node_path7 = require("path");
5844
+ var BUILD_ENV = contract_default.publish.build_env;
5845
+ var PUBLIC_PREFIXES = BUILD_ENV.public_prefixes;
5846
+ var BUILTINS = new Set(BUILD_ENV.bundler_builtins);
5847
+ var INJECTABLE_NAMES = new Set(
5848
+ BUILD_ENV.injected.flatMap((e) => e.prefixes.map((p) => `${p}${e.suffix}`))
5849
+ );
5850
+ var ALWAYS_DEFINED_NAMES = new Set(
5851
+ BUILD_ENV.injected.filter((e) => e.when === "always").flatMap((e) => e.prefixes.map((p) => `${p}${e.suffix}`))
5852
+ );
5853
+ var splitName = (name) => {
5854
+ const prefix = PUBLIC_PREFIXES.find((p) => name.startsWith(p));
5855
+ return prefix ? { prefix, suffix: name.slice(prefix.length) } : null;
5856
+ };
5857
+ var injectedEntry = (suffix) => BUILD_ENV.injected.find((e) => e.suffix === suffix);
5858
+ var SCAN_FILE = /\.(js|jsx|ts|tsx|mjs|cjs|vue|svelte|astro)$/;
5859
+ var SKIP_FILE = /\.d\.ts$|\.(test|spec)\.[a-z]+$/;
5860
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "dist", "build", "out", ".nuxt", ".output", "coverage"]);
5861
+ var SCAN_FILE_BUDGET = 5e3;
5862
+ var MAX_SCAN_BYTES = 1024 * 1024;
5863
+ var ENV_OBJECT = String.raw`(?:import\.meta\.env|process\.env)`;
5864
+ var MEMBER_READ = new RegExp(
5865
+ String.raw`\b${ENV_OBJECT}\s*(?:\??\.\s*([A-Z][A-Z0-9_]*)|\[\s*['"]([A-Z][A-Z0-9_]*)['"]\s*\])(\s*(?:\?\?|\|\|))?`,
5866
+ "g"
5867
+ );
5868
+ var DESTRUCTURED_READ = new RegExp(String.raw`\{([^{}]*)\}\s*=\s*${ENV_OBJECT}\b`, "g");
5869
+ var QUOTED = /'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/g;
5870
+ var MENTIONS_ENV = /\benv\s*[.[]/;
5871
+ var stripNonCode = (text) => text.replace(QUOTED, (quoted) => MENTIONS_ENV.test(quoted) ? "''" : quoted).replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:'"`\\])\/\/[^\n]*/g, "$1");
5872
+ var isReportable = (name) => !BUILTINS.has(name) && splitName(name) !== null;
5873
+ function collectFiles2(dir, out, budget) {
5874
+ if (budget.left <= 0 || !(0, import_node_fs5.existsSync)(dir)) return;
5875
+ for (const entry of (0, import_node_fs5.readdirSync)(dir, { withFileTypes: true })) {
5876
+ if (budget.left <= 0) return;
5877
+ const abs = (0, import_node_path7.join)(dir, entry.name);
5878
+ if (entry.isDirectory()) {
5879
+ if (!SKIP_DIRS.has(entry.name)) collectFiles2(abs, out, budget);
5880
+ } else if (entry.isFile() && SCAN_FILE.test(entry.name) && !SKIP_FILE.test(entry.name)) {
5881
+ budget.left--;
5882
+ out.push(abs);
5883
+ }
5884
+ }
5885
+ }
5886
+ function scanEnvReads(rootDir) {
5887
+ const files = [];
5888
+ collectFiles2(rootDir, files, { left: SCAN_FILE_BUDGET });
5889
+ const reads = [];
5890
+ for (const abs of files) {
5891
+ let text;
5892
+ try {
5893
+ text = (0, import_node_fs5.readFileSync)(abs, "utf8");
5894
+ } catch {
5895
+ continue;
5896
+ }
5897
+ if (text.length > MAX_SCAN_BYTES || !/\benv\b/.test(text)) continue;
5898
+ text = stripNonCode(text);
5899
+ const inFile = /* @__PURE__ */ new Map();
5900
+ const note = (name, hasFallback) => {
5901
+ if (isReportable(name)) inFile.set(name, (inFile.get(name) ?? true) && hasFallback);
5902
+ };
5903
+ for (const m of text.matchAll(MEMBER_READ)) note(m[1] || m[2], Boolean(m[3]));
5904
+ for (const m of text.matchAll(DESTRUCTURED_READ)) {
5905
+ for (const part of m[1].split(",")) {
5906
+ const field = /^\s*([A-Z][A-Z0-9_]*)\s*(?::\s*[A-Za-z_$][\w$]*\s*)?(=)?/.exec(part);
5907
+ if (field) note(field[1], Boolean(field[2]));
5908
+ }
5909
+ }
5910
+ const file = (0, import_node_path7.relative)(rootDir, abs).split(import_node_path7.sep).join("/");
5911
+ for (const [name, hasFallback] of inFile) reads.push({ name, file, hasFallback });
5912
+ }
5913
+ return reads;
5914
+ }
5915
+ function envFileNames(rootDir) {
5916
+ const names = /* @__PURE__ */ new Set();
5917
+ if (!(0, import_node_fs5.existsSync)(rootDir)) return names;
5918
+ for (const entry of (0, import_node_fs5.readdirSync)(rootDir, { withFileTypes: true })) {
5919
+ if (!entry.isFile() || !(entry.name === ".env" || entry.name.startsWith(".env."))) continue;
5920
+ let text;
5921
+ try {
5922
+ text = (0, import_node_fs5.readFileSync)((0, import_node_path7.join)(rootDir, entry.name), "utf8");
5923
+ } catch {
5924
+ continue;
5925
+ }
5926
+ for (const line of text.split("\n")) {
5927
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
5928
+ if (m) names.add(m[1]);
5929
+ }
5930
+ }
5931
+ return names;
5932
+ }
5933
+ var sampleFiles = (files) => files.slice(0, 3).join(", ") + (files.length > 3 ? ", \u2026" : "");
5934
+ var prefixList = (prefixes) => prefixes.map((p) => `${p}*`).join("/");
5935
+ function platformHint(name, opts) {
5936
+ const { prefix, suffix } = splitName(name);
5937
+ const entry = injectedEntry(suffix);
5938
+ if (!entry.prefixes.includes(prefix)) {
5939
+ return `The platform defines ${suffix} only as ${entry.prefixes.map((p) => `${p}${suffix}`).join(" and ")} \u2014 read one of those, or keep the value in an ordinary source constant.`;
5940
+ }
5941
+ if (entry.when === "byo_supabase") {
5942
+ if ((opts.rejected || []).includes(suffix)) {
5943
+ return "The key stored for this database looks like a Supabase SECRET key. The platform refuses to put it into a public bundle \u2014 store the publishable (anon) key as `database.anon_key` in the Backend tab instead.";
5944
+ }
5945
+ if (opts.provider === "supabase" && (opts.missing || []).includes(suffix)) {
5946
+ return suffix === "SUPABASE_URL" ? "Your own Supabase database is connected, but no https project URL is stored \u2014 set it in the Backend tab (Database)." : "Your own Supabase database is connected, but no publishable key is stored \u2014 add it in the Backend tab as `database.anon_key`.";
5947
+ }
5948
+ return `The platform defines ${suffix} (as ${prefixList(entry.prefixes)}) only while your own Supabase database is connected in the Backend tab (Database \u2192 own Supabase). Connect it there, or keep the public value in an ordinary source constant.`;
5949
+ }
5950
+ return "Keep the public value in an ordinary source constant.";
5951
+ }
5952
+ function judgeEnvReads(reads, opts) {
5953
+ const byName = /* @__PURE__ */ new Map();
5954
+ for (const read of reads) {
5955
+ if (opts.defined.has(read.name)) continue;
5956
+ byName.set(read.name, [...byName.get(read.name) || [], read]);
5957
+ }
5958
+ const verdict = { errors: [], warnings: [] };
5959
+ const build = opts.backendUnknown ? "the cloud build" : "this build";
5960
+ for (const name of [...byName.keys()].sort()) {
5961
+ const group = byName.get(name);
5962
+ const files = sampleFiles(group.map((r) => r.file).sort());
5963
+ const { prefix, suffix } = splitName(name);
5964
+ const entry = injectedEntry(suffix);
5965
+ const taught = entry !== void 0;
5966
+ const bare = group.filter((r) => !r.hasFallback);
5967
+ const local = opts.localOnly?.has(name) ? " Your local .env defines it, but .env files never leave this machine." : "";
5968
+ if (opts.backendUnknown && entry && entry.when === "byo_supabase" && entry.prefixes.includes(prefix)) {
5969
+ verdict.warnings.push(
5970
+ `${name} is read in ${files}. The cloud build defines it only while your own Supabase database is connected in the Backend tab (Database \u2192 own Supabase)` + (bare.length > 0 ? " \u2014 without that it rejects the build, because the value would compile to `undefined` without any build error." : "; without that the app runs on its own fallback.") + local
5971
+ );
5972
+ } else if (taught && bare.length > 0) {
5973
+ verdict.errors.push(
5974
+ `${name} is read in ${sampleFiles(bare.map((r) => r.file).sort())}, but ${build} does not define it \u2014 it would compile to \`undefined\` without any build error. ${platformHint(name, opts)}${local}`
5975
+ );
5976
+ } else if (taught) {
5977
+ verdict.warnings.push(
5978
+ `${name} is read in ${files} and ${build} does not define it \u2014 the app runs on its own fallback. ${platformHint(name, opts)}${local}`
5979
+ );
5980
+ } else {
5981
+ verdict.warnings.push(
5982
+ `${name} is read in ${files}, but no cloud build defines it \u2014 it compiles to \`undefined\`. This platform sets no build variables of your own: keep public values in an ordinary source constant (a fallback such as \`?? '/api'\` is fine).${local}`
5983
+ );
5984
+ }
5985
+ }
5986
+ return verdict;
5987
+ }
5988
+
5829
5989
  // src/lib/preflight.ts
5830
5990
  function feature(id) {
5831
5991
  const found = contract_default.forbidden_server_features.find((f) => f.id === id);
@@ -5853,14 +6013,14 @@ var STATIC_EXPORT = /output\s*:\s*['"]export['"]/;
5853
6013
  var SOURCE_FILE = /\.(js|jsx|ts|tsx|mjs)$/;
5854
6014
  var GET_SERVER_SIDE_PROPS = /export\s+(?:async\s+)?(?:function|const|let|var)\s+getServerSideProps\b|export\s*\{[^}]*\bgetServerSideProps\b[^}]*\}/;
5855
6015
  var USE_SERVER = /^\s*(['"])use server\1\s*;?\s*$/m;
5856
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "dist", "build", "out"]);
5857
- var SCAN_FILE_BUDGET = 5e3;
6016
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "dist", "build", "out"]);
6017
+ var SCAN_FILE_BUDGET2 = 5e3;
5858
6018
  function detectFramework(rootDir) {
5859
- const pkgPath = (0, import_node_path7.join)(rootDir, "package.json");
5860
- if (!(0, import_node_fs5.existsSync)(pkgPath)) return "vanilla";
6019
+ const pkgPath = (0, import_node_path8.join)(rootDir, "package.json");
6020
+ if (!(0, import_node_fs6.existsSync)(pkgPath)) return "vanilla";
5861
6021
  let pkg;
5862
6022
  try {
5863
- pkg = JSON.parse((0, import_node_fs5.readFileSync)(pkgPath, "utf8"));
6023
+ pkg = JSON.parse((0, import_node_fs6.readFileSync)(pkgPath, "utf8"));
5864
6024
  } catch {
5865
6025
  return "unknown";
5866
6026
  }
@@ -5870,32 +6030,32 @@ function detectFramework(rootDir) {
5870
6030
  }
5871
6031
  function readDeps(rootDir) {
5872
6032
  try {
5873
- const pkg = JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path7.join)(rootDir, "package.json"), "utf8"));
6033
+ const pkg = JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path8.join)(rootDir, "package.json"), "utf8"));
5874
6034
  return { ...pkg.dependencies, ...pkg.devDependencies };
5875
6035
  } catch {
5876
6036
  return {};
5877
6037
  }
5878
6038
  }
5879
6039
  function findFiles(rootDir, dir, pattern, out, cap = 20) {
5880
- if (out.length >= cap || !(0, import_node_fs5.existsSync)(dir)) return;
5881
- for (const entry of (0, import_node_fs5.readdirSync)(dir, { withFileTypes: true })) {
6040
+ if (out.length >= cap || !(0, import_node_fs6.existsSync)(dir)) return;
6041
+ for (const entry of (0, import_node_fs6.readdirSync)(dir, { withFileTypes: true })) {
5882
6042
  if (out.length >= cap) return;
5883
- const abs = (0, import_node_path7.join)(dir, entry.name);
6043
+ const abs = (0, import_node_path8.join)(dir, entry.name);
5884
6044
  if (entry.isDirectory()) {
5885
- if (!SKIP_DIRS.has(entry.name)) findFiles(rootDir, abs, pattern, out, cap);
6045
+ if (!SKIP_DIRS2.has(entry.name)) findFiles(rootDir, abs, pattern, out, cap);
5886
6046
  } else if (entry.isFile() && pattern.test(entry.name)) {
5887
- out.push((0, import_node_path7.relative)(rootDir, abs).split(import_node_path7.sep).join("/"));
6047
+ out.push((0, import_node_path8.relative)(rootDir, abs).split(import_node_path8.sep).join("/"));
5888
6048
  }
5889
6049
  }
5890
6050
  }
5891
6051
  var sample = (list) => list.slice(0, 3).join(", ") + (list.length > 3 ? ", \u2026" : "");
5892
6052
  function collectSourceFiles(dir, out, budget) {
5893
- if (budget.left <= 0 || !(0, import_node_fs5.existsSync)(dir)) return;
5894
- for (const entry of (0, import_node_fs5.readdirSync)(dir, { withFileTypes: true })) {
6053
+ if (budget.left <= 0 || !(0, import_node_fs6.existsSync)(dir)) return;
6054
+ for (const entry of (0, import_node_fs6.readdirSync)(dir, { withFileTypes: true })) {
5895
6055
  if (budget.left <= 0) return;
5896
- const abs = (0, import_node_path7.join)(dir, entry.name);
6056
+ const abs = (0, import_node_path8.join)(dir, entry.name);
5897
6057
  if (entry.isDirectory()) {
5898
- if (!SKIP_DIRS.has(entry.name)) collectSourceFiles(abs, out, budget);
6058
+ if (!SKIP_DIRS2.has(entry.name)) collectSourceFiles(abs, out, budget);
5899
6059
  } else if (entry.isFile() && SOURCE_FILE.test(entry.name)) {
5900
6060
  budget.left--;
5901
6061
  out.push(abs);
@@ -5905,10 +6065,10 @@ function collectSourceFiles(dir, out, budget) {
5905
6065
  function checkNextjs(rootDir) {
5906
6066
  const issues = [];
5907
6067
  const warnings = [];
5908
- const configName = NEXT_CONFIG_BASENAMES.find((name) => (0, import_node_fs5.existsSync)((0, import_node_path7.join)(rootDir, name)));
6068
+ const configName = NEXT_CONFIG_BASENAMES.find((name) => (0, import_node_fs6.existsSync)((0, import_node_path8.join)(rootDir, name)));
5909
6069
  let hasStaticExport = true;
5910
6070
  if (configName) {
5911
- const config = (0, import_node_fs5.readFileSync)((0, import_node_path7.join)(rootDir, configName), "utf8");
6071
+ const config = (0, import_node_fs6.readFileSync)((0, import_node_path8.join)(rootDir, configName), "utf8");
5912
6072
  hasStaticExport = STATIC_EXPORT.test(config);
5913
6073
  if (!hasStaticExport) {
5914
6074
  issues.push(
@@ -5922,31 +6082,31 @@ function checkNextjs(rootDir) {
5922
6082
  const hard = [];
5923
6083
  const pagesApi = [];
5924
6084
  for (const apiDir of PAGES_API_DIRS) {
5925
- findFiles(rootDir, (0, import_node_path7.join)(rootDir, apiDir), /\.(js|ts|jsx|tsx|mjs)$/, pagesApi);
6085
+ findFiles(rootDir, (0, import_node_path8.join)(rootDir, apiDir), /\.(js|ts|jsx|tsx|mjs)$/, pagesApi);
5926
6086
  }
5927
6087
  if (pagesApi.length > 0) hard.push(`pages/api/* routes (${pagesApi.length} file(s))`);
5928
6088
  const middlewareFiles = [];
5929
6089
  for (const base of ["", "src"]) {
5930
- const dir = base ? (0, import_node_path7.join)(rootDir, base) : rootDir;
5931
- if (!(0, import_node_fs5.existsSync)(dir)) continue;
5932
- for (const entry of (0, import_node_fs5.readdirSync)(dir)) {
5933
- if (MIDDLEWARE_FILE.test(entry)) middlewareFiles.push((0, import_node_path7.join)(base, entry).split(import_node_path7.sep).join("/"));
6090
+ const dir = base ? (0, import_node_path8.join)(rootDir, base) : rootDir;
6091
+ if (!(0, import_node_fs6.existsSync)(dir)) continue;
6092
+ for (const entry of (0, import_node_fs6.readdirSync)(dir)) {
6093
+ if (MIDDLEWARE_FILE.test(entry)) middlewareFiles.push((0, import_node_path8.join)(base, entry).split(import_node_path8.sep).join("/"));
5934
6094
  }
5935
6095
  }
5936
6096
  hard.push(...middlewareFiles);
5937
6097
  const sourceFiles = [];
5938
- collectSourceFiles(rootDir, sourceFiles, { left: SCAN_FILE_BUDGET });
6098
+ collectSourceFiles(rootDir, sourceFiles, { left: SCAN_FILE_BUDGET2 });
5939
6099
  const gssp = [];
5940
6100
  const useServer = [];
5941
6101
  for (const abs of sourceFiles) {
5942
6102
  if (gssp.length >= 20 && useServer.length >= 20) break;
5943
6103
  let text;
5944
6104
  try {
5945
- text = (0, import_node_fs5.readFileSync)(abs, "utf8");
6105
+ text = (0, import_node_fs6.readFileSync)(abs, "utf8");
5946
6106
  } catch {
5947
6107
  continue;
5948
6108
  }
5949
- const rel = (0, import_node_path7.relative)(rootDir, abs).split(import_node_path7.sep).join("/");
6109
+ const rel = (0, import_node_path8.relative)(rootDir, abs).split(import_node_path8.sep).join("/");
5950
6110
  if (gssp.length < 20 && GET_SERVER_SIDE_PROPS.test(text)) gssp.push(rel);
5951
6111
  if (useServer.length < 20 && USE_SERVER.test(text)) useServer.push(rel);
5952
6112
  }
@@ -5954,7 +6114,7 @@ function checkNextjs(rootDir) {
5954
6114
  if (useServer.length > 0) hard.push(`'use server' actions (${sample(useServer)})`);
5955
6115
  const routeFiles = [];
5956
6116
  for (const appDir of APP_DIRS) {
5957
- findFiles(rootDir, (0, import_node_path7.join)(rootDir, appDir), ROUTE_HANDLER_FILE, routeFiles);
6117
+ findFiles(rootDir, (0, import_node_path8.join)(rootDir, appDir), ROUTE_HANDLER_FILE, routeFiles);
5958
6118
  }
5959
6119
  if (routeFiles.length > 0) {
5960
6120
  const label = `${routeFiles.length} route handler(s) (${sample(routeFiles)})`;
@@ -5988,6 +6148,17 @@ function checkNextjs(rootDir) {
5988
6148
  }
5989
6149
  return { issues, warnings };
5990
6150
  }
6151
+ function checkBuildEnv(rootDir) {
6152
+ const verdict = judgeEnvReads(scanEnvReads(rootDir), {
6153
+ defined: ALWAYS_DEFINED_NAMES,
6154
+ backendUnknown: true,
6155
+ localOnly: envFileNames(rootDir)
6156
+ });
6157
+ return [
6158
+ ...verdict.errors.map((e) => `The cloud build will reject this project: ${e}`),
6159
+ ...verdict.warnings
6160
+ ];
6161
+ }
5991
6162
  function runPreflight(rootDir) {
5992
6163
  const framework = detectFramework(rootDir);
5993
6164
  const issues = [];
@@ -6002,17 +6173,18 @@ function runPreflight(rootDir) {
6002
6173
  issues.push(...next.issues);
6003
6174
  warnings.push(...next.warnings);
6004
6175
  }
6176
+ if (framework !== "vanilla") warnings.push(...checkBuildEnv(rootDir));
6005
6177
  return { framework, issues, warnings };
6006
6178
  }
6007
6179
 
6008
6180
  // src/lib/backendDecl.ts
6009
- var import_node_fs6 = require("fs");
6010
- var import_node_path8 = require("path");
6181
+ var import_node_fs7 = require("fs");
6182
+ var import_node_path9 = require("path");
6011
6183
  function readBackendDeclaration(dir = process.cwd()) {
6012
- const path = (0, import_node_path8.join)(dir, ".agentful", "backend.json");
6013
- if (!(0, import_node_fs6.existsSync)(path)) return null;
6184
+ const path = (0, import_node_path9.join)(dir, ".agentful", "backend.json");
6185
+ if (!(0, import_node_fs7.existsSync)(path)) return null;
6014
6186
  try {
6015
- const parsed = JSON.parse((0, import_node_fs6.readFileSync)(path, "utf8"));
6187
+ const parsed = JSON.parse((0, import_node_fs7.readFileSync)(path, "utf8"));
6016
6188
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
6017
6189
  const collections = Array.isArray(parsed.collections) ? parsed.collections.filter((c) => c && typeof c === "object" && !Array.isArray(c)) : [];
6018
6190
  const secrets = Array.isArray(parsed.secrets) ? parsed.secrets.filter((s) => s && typeof s === "object" && !Array.isArray(s)) : [];
@@ -6056,19 +6228,19 @@ function describeBackendDeclaration(decl) {
6056
6228
  function backendTabUrl(userId, projectId) {
6057
6229
  return `${APP_URL}/workspace/${userId}/${projectId}?view=backend`;
6058
6230
  }
6059
- var ACTIONS_DIR = (0, import_node_path8.join)(".server", "actions");
6231
+ var ACTIONS_DIR = (0, import_node_path9.join)(".server", "actions");
6060
6232
  var ACTION_NAME = /^[a-z][a-z0-9_-]{0,62}$/;
6061
6233
  function checkBackendDeclaration(rootDir = process.cwd()) {
6062
6234
  const issues = [];
6063
6235
  const warnings = [];
6064
- const actionsDir = (0, import_node_path8.join)(rootDir, ACTIONS_DIR);
6236
+ const actionsDir = (0, import_node_path9.join)(rootDir, ACTIONS_DIR);
6065
6237
  const presentFiles = /* @__PURE__ */ new Set();
6066
- if ((0, import_node_fs6.existsSync)(actionsDir)) {
6067
- for (const entry of (0, import_node_fs6.readdirSync)(actionsDir)) {
6068
- const abs = (0, import_node_path8.join)(actionsDir, entry);
6238
+ if ((0, import_node_fs7.existsSync)(actionsDir)) {
6239
+ for (const entry of (0, import_node_fs7.readdirSync)(actionsDir)) {
6240
+ const abs = (0, import_node_path9.join)(actionsDir, entry);
6069
6241
  let isDir = false;
6070
6242
  try {
6071
- isDir = (0, import_node_fs6.statSync)(abs).isDirectory();
6243
+ isDir = (0, import_node_fs7.statSync)(abs).isDirectory();
6072
6244
  } catch {
6073
6245
  continue;
6074
6246
  }
@@ -6285,8 +6457,8 @@ function describeBackendState(state) {
6285
6457
  }
6286
6458
 
6287
6459
  // src/lib/backendConsent.ts
6288
- var import_node_fs7 = require("fs");
6289
- var import_node_path9 = require("path");
6460
+ var import_node_fs8 = require("fs");
6461
+ var import_node_path10 = require("path");
6290
6462
  var import_promises2 = require("readline/promises");
6291
6463
  function declaresManagedDatabase(decl) {
6292
6464
  if (!decl) return false;
@@ -6312,10 +6484,10 @@ function consentQuestion(decl) {
6312
6484
  'It is the same switch as the Backend tab; this push then applies the declaration. A "no" is remembered for this project (`agentful push --yes` or `agentful backend` enables it later).'
6313
6485
  ].join("\n");
6314
6486
  }
6315
- var consentFile = () => (0, import_node_path9.join)(configDir(), "backend-consent.json");
6487
+ var consentFile = () => (0, import_node_path10.join)(configDir(), "backend-consent.json");
6316
6488
  function readConsentFile() {
6317
6489
  try {
6318
- const raw = JSON.parse((0, import_node_fs7.readFileSync)(consentFile(), "utf8"));
6490
+ const raw = JSON.parse((0, import_node_fs8.readFileSync)(consentFile(), "utf8"));
6319
6491
  return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
6320
6492
  } catch {
6321
6493
  return {};
@@ -6327,15 +6499,15 @@ function hasRememberedDecline(projectId) {
6327
6499
  function rememberDecline(projectId) {
6328
6500
  const all = readConsentFile();
6329
6501
  all[projectId] = { declined_at: (/* @__PURE__ */ new Date()).toISOString() };
6330
- (0, import_node_fs7.mkdirSync)(configDir(), { recursive: true });
6331
- (0, import_node_fs7.writeFileSync)(consentFile(), JSON.stringify(all, null, 2) + "\n", "utf8");
6502
+ (0, import_node_fs8.mkdirSync)(configDir(), { recursive: true });
6503
+ (0, import_node_fs8.writeFileSync)(consentFile(), JSON.stringify(all, null, 2) + "\n", "utf8");
6332
6504
  }
6333
6505
  function forgetDecline(projectId) {
6334
- if (!(0, import_node_fs7.existsSync)(consentFile())) return;
6506
+ if (!(0, import_node_fs8.existsSync)(consentFile())) return;
6335
6507
  const all = readConsentFile();
6336
6508
  if (!(projectId in all)) return;
6337
6509
  delete all[projectId];
6338
- (0, import_node_fs7.writeFileSync)(consentFile(), JSON.stringify(all, null, 2) + "\n", "utf8");
6510
+ (0, import_node_fs8.writeFileSync)(consentFile(), JSON.stringify(all, null, 2) + "\n", "utf8");
6339
6511
  }
6340
6512
  async function enableManagedDatabase(auth, userId, projectId) {
6341
6513
  try {
@@ -6543,13 +6715,14 @@ async function pushCommand(opts) {
6543
6715
  ui.step("Building in the cloud (this can take a few minutes)\u2026");
6544
6716
  const buildStartedAt = Date.now();
6545
6717
  try {
6546
- await request(`${PB_URL}/api/workspaces/${userId}/${projectId}/build`, {
6718
+ const built = await request(`${PB_URL}/api/workspaces/${userId}/${projectId}/build`, {
6547
6719
  method: "POST",
6548
6720
  token: auth.pb_token,
6549
6721
  body: {},
6550
6722
  timeoutMs: 66e4
6551
6723
  });
6552
6724
  ui.info("Build finished");
6725
+ for (const warning of built?.warnings ?? []) ui.warn(warning);
6553
6726
  } catch (err2) {
6554
6727
  if (err2 instanceof ApiError && err2.code === "network_error") {
6555
6728
  ui.warn("Connection dropped during the build \u2014 polling for the result\u2026");
@@ -6712,15 +6885,15 @@ async function openCommand() {
6712
6885
  // src/commands/tui.ts
6713
6886
  var import_node_child_process6 = require("child_process");
6714
6887
  var import_promises4 = require("readline/promises");
6715
- var import_node_path20 = require("path");
6888
+ var import_node_path21 = require("path");
6716
6889
 
6717
6890
  // src/lib/binWrapper.ts
6718
- var import_node_fs8 = require("fs");
6719
- var import_node_path10 = require("path");
6891
+ var import_node_fs9 = require("fs");
6892
+ var import_node_path11 = require("path");
6720
6893
  var import_node_os3 = require("os");
6721
6894
  function ensureCliOnPath() {
6722
- const binDir = (0, import_node_path10.join)((0, import_node_os3.homedir)(), ".local", "share", "agentful", "bin");
6723
- (0, import_node_fs8.mkdirSync)(binDir, { recursive: true });
6895
+ const binDir = (0, import_node_path11.join)((0, import_node_os3.homedir)(), ".local", "share", "agentful", "bin");
6896
+ (0, import_node_fs9.mkdirSync)(binDir, { recursive: true });
6724
6897
  const entry = process.argv[1];
6725
6898
  if (!entry) return binDir;
6726
6899
  const restore = (name) => {
@@ -6729,8 +6902,8 @@ function ensureCliOnPath() {
6729
6902
  ` : `export ${name}=${JSON.stringify(original)}
6730
6903
  `;
6731
6904
  };
6732
- const wrapper = (0, import_node_path10.join)(binDir, "agentful");
6733
- (0, import_node_fs8.writeFileSync)(
6905
+ const wrapper = (0, import_node_path11.join)(binDir, "agentful");
6906
+ (0, import_node_fs9.writeFileSync)(
6734
6907
  wrapper,
6735
6908
  `#!/bin/sh
6736
6909
  # Generated by the Agentful CLI so the coding agent can run \`agentful \u2026\`.
@@ -6740,7 +6913,7 @@ function ensureCliOnPath() {
6740
6913
  "utf8"
6741
6914
  );
6742
6915
  try {
6743
- (0, import_node_fs8.chmodSync)(wrapper, 493);
6916
+ (0, import_node_fs9.chmodSync)(wrapper, 493);
6744
6917
  } catch {
6745
6918
  }
6746
6919
  return binDir;
@@ -6750,9 +6923,9 @@ function ensureCliOnPath() {
6750
6923
  init_branding();
6751
6924
 
6752
6925
  // src/lib/engine.ts
6753
- var import_node_fs9 = require("fs");
6926
+ var import_node_fs10 = require("fs");
6754
6927
  var import_node_crypto = require("crypto");
6755
- var import_node_path11 = require("path");
6928
+ var import_node_path12 = require("path");
6756
6929
  var import_node_os4 = require("os");
6757
6930
  var import_node_child_process3 = require("child_process");
6758
6931
  var import_node_stream = require("stream");
@@ -6797,8 +6970,8 @@ function platformKey() {
6797
6970
  );
6798
6971
  }
6799
6972
  function cacheDir(version) {
6800
- const base = process.env.XDG_CACHE_HOME || (0, import_node_path11.join)((0, import_node_os4.homedir)(), ".cache");
6801
- return (0, import_node_path11.join)(base, "agentful", "engine", version);
6973
+ const base = process.env.XDG_CACHE_HOME || (0, import_node_path12.join)((0, import_node_os4.homedir)(), ".cache");
6974
+ return (0, import_node_path12.join)(base, "agentful", "engine", version);
6802
6975
  }
6803
6976
  async function fetchManifest() {
6804
6977
  try {
@@ -6809,7 +6982,7 @@ async function fetchManifest() {
6809
6982
  }
6810
6983
  async function sha256File(path) {
6811
6984
  const hash = (0, import_node_crypto.createHash)("sha256");
6812
- await (0, import_promises3.pipeline)((0, import_node_fs9.createReadStream)(path), hash);
6985
+ await (0, import_promises3.pipeline)((0, import_node_fs10.createReadStream)(path), hash);
6813
6986
  return hash.digest("hex");
6814
6987
  }
6815
6988
  async function ensureEngine() {
@@ -6818,12 +6991,12 @@ async function ensureEngine() {
6818
6991
  noticeIfOutdated(manifest);
6819
6992
  const version = manifest?.engine_version || FALLBACK_ENGINE_VERSION;
6820
6993
  const dir = cacheDir(version);
6821
- const binPath = (0, import_node_path11.join)(dir, ENGINE_BINARY);
6822
- if ((0, import_node_fs9.existsSync)(binPath)) return binPath;
6994
+ const binPath = (0, import_node_path12.join)(dir, ENGINE_BINARY);
6995
+ if ((0, import_node_fs10.existsSync)(binPath)) return binPath;
6823
6996
  const artifact = manifest?.artifacts?.[key];
6824
6997
  const url = artifact?.url || `${APP_URL}/cli/engine/${version}/${key}${key.startsWith("darwin") ? ".zip" : ".tar.gz"}`;
6825
- (0, import_node_fs9.mkdirSync)(dir, { recursive: true });
6826
- const archivePath = (0, import_node_path11.join)(dir, key.startsWith("darwin") ? "engine.zip" : "engine.tar.gz");
6998
+ (0, import_node_fs10.mkdirSync)(dir, { recursive: true });
6999
+ const archivePath = (0, import_node_path12.join)(dir, key.startsWith("darwin") ? "engine.zip" : "engine.tar.gz");
6827
7000
  ui.step(`Preparing the ${brand.displayName} engine (first run only)\u2026`);
6828
7001
  const resp = await fetch(url, {
6829
7002
  redirect: "follow",
@@ -6837,11 +7010,11 @@ async function ensureEngine() {
6837
7010
  `Could not download the engine (HTTP ${resp.status}). Check your connection and try again.`
6838
7011
  );
6839
7012
  }
6840
- await (0, import_promises3.pipeline)(import_node_stream.Readable.fromWeb(resp.body), (0, import_node_fs9.createWriteStream)(archivePath));
7013
+ await (0, import_promises3.pipeline)(import_node_stream.Readable.fromWeb(resp.body), (0, import_node_fs10.createWriteStream)(archivePath));
6841
7014
  if (artifact?.sha256) {
6842
7015
  const actual = await sha256File(archivePath);
6843
7016
  if (actual !== artifact.sha256) {
6844
- (0, import_node_fs9.rmSync)(archivePath, { force: true });
7017
+ (0, import_node_fs10.rmSync)(archivePath, { force: true });
6845
7018
  throw new ApiError(
6846
7019
  0,
6847
7020
  "engine_checksum_mismatch",
@@ -6850,7 +7023,7 @@ async function ensureEngine() {
6850
7023
  }
6851
7024
  }
6852
7025
  const extract = archivePath.endsWith(".zip") ? (0, import_node_child_process3.spawnSync)("unzip", ["-oq", archivePath], { cwd: dir, stdio: "ignore" }) : (0, import_node_child_process3.spawnSync)("tar", ["-xzf", archivePath], { cwd: dir, stdio: "ignore" });
6853
- (0, import_node_fs9.rmSync)(archivePath, { force: true });
7026
+ (0, import_node_fs10.rmSync)(archivePath, { force: true });
6854
7027
  if (extract.status !== 0) {
6855
7028
  throw new ApiError(
6856
7029
  0,
@@ -6858,24 +7031,24 @@ async function ensureEngine() {
6858
7031
  `Could not unpack the engine (needs ${archivePath.endsWith(".zip") ? "unzip" : "tar"} on PATH).`
6859
7032
  );
6860
7033
  }
6861
- const extracted = (0, import_node_path11.join)(dir, "opencode");
6862
- if ((0, import_node_fs9.existsSync)(extracted) && !(0, import_node_fs9.existsSync)(binPath)) (0, import_node_fs9.renameSync)(extracted, binPath);
6863
- if (!(0, import_node_fs9.existsSync)(binPath)) {
7034
+ const extracted = (0, import_node_path12.join)(dir, "opencode");
7035
+ if ((0, import_node_fs10.existsSync)(extracted) && !(0, import_node_fs10.existsSync)(binPath)) (0, import_node_fs10.renameSync)(extracted, binPath);
7036
+ if (!(0, import_node_fs10.existsSync)(binPath)) {
6864
7037
  throw new ApiError(0, "engine_extract_failed", "Engine archive did not contain the expected binary.");
6865
7038
  }
6866
- (0, import_node_fs9.chmodSync)(binPath, 493);
7039
+ (0, import_node_fs10.chmodSync)(binPath, 493);
6867
7040
  ui.ok(`Engine ready.`);
6868
7041
  console.log(paint.dim(" powered by opencode (MIT) \xB7 run `agentful licenses` for details"));
6869
7042
  return binPath;
6870
7043
  }
6871
7044
 
6872
7045
  // src/lib/gatewayToken.ts
6873
- var import_node_fs10 = require("fs");
6874
- var import_node_path12 = require("path");
6875
- var cachePath = () => (0, import_node_path12.join)(configDir(), "gateway.json");
7046
+ var import_node_fs11 = require("fs");
7047
+ var import_node_path13 = require("path");
7048
+ var cachePath = () => (0, import_node_path13.join)(configDir(), "gateway.json");
6876
7049
  async function ensureGatewaySession(auth, projectId) {
6877
7050
  try {
6878
- const cached = JSON.parse((0, import_node_fs10.readFileSync)(cachePath(), "utf8"));
7051
+ const cached = JSON.parse((0, import_node_fs11.readFileSync)(cachePath(), "utf8"));
6879
7052
  if (cached?.token && cached.expires_at - Date.now() / 1e3 > 3600) {
6880
7053
  return cached;
6881
7054
  }
@@ -6886,18 +7059,18 @@ async function ensureGatewaySession(auth, projectId) {
6886
7059
  body: projectId ? { project_id: projectId } : {},
6887
7060
  timeoutMs: 3e4
6888
7061
  });
6889
- (0, import_node_fs10.mkdirSync)(configDir(), { recursive: true });
6890
- (0, import_node_fs10.writeFileSync)(cachePath(), JSON.stringify(res, null, 2) + "\n", "utf8");
7062
+ (0, import_node_fs11.mkdirSync)(configDir(), { recursive: true });
7063
+ (0, import_node_fs11.writeFileSync)(cachePath(), JSON.stringify(res, null, 2) + "\n", "utf8");
6891
7064
  try {
6892
- (0, import_node_fs10.chmodSync)(cachePath(), 384);
7065
+ (0, import_node_fs11.chmodSync)(cachePath(), 384);
6893
7066
  } catch {
6894
7067
  }
6895
7068
  return res;
6896
7069
  }
6897
7070
 
6898
7071
  // src/lib/engineConfig.ts
6899
- var import_node_fs12 = require("fs");
6900
- var import_node_path14 = require("path");
7072
+ var import_node_fs13 = require("fs");
7073
+ var import_node_path15 = require("path");
6901
7074
  var import_node_os5 = require("os");
6902
7075
 
6903
7076
  // src/branding/agentful-theme.json
@@ -7040,22 +7213,22 @@ ${cmd.template}
7040
7213
  }
7041
7214
 
7042
7215
  // src/lib/tools.ts
7043
- var import_node_fs11 = require("fs");
7044
- var import_node_path13 = require("path");
7216
+ var import_node_fs12 = require("fs");
7217
+ var import_node_path14 = require("path");
7045
7218
  function assetsDir() {
7046
- const here = (0, import_node_path13.dirname)(process.argv[1] || "");
7047
- for (const candidate of [(0, import_node_path13.join)(here, "..", "assets"), (0, import_node_path13.join)(here, "assets")]) {
7048
- if ((0, import_node_fs11.existsSync)((0, import_node_path13.join)(candidate, "tools", "imagegen.ts"))) return candidate;
7219
+ const here = (0, import_node_path14.dirname)(process.argv[1] || "");
7220
+ for (const candidate of [(0, import_node_path14.join)(here, "..", "assets"), (0, import_node_path14.join)(here, "assets")]) {
7221
+ if ((0, import_node_fs12.existsSync)((0, import_node_path14.join)(candidate, "tools", "imagegen.ts"))) return candidate;
7049
7222
  }
7050
7223
  return null;
7051
7224
  }
7052
7225
  function installEngineTools(configDir2) {
7053
7226
  const assets = assetsDir();
7054
7227
  if (!assets) return false;
7055
- const target = (0, import_node_path13.join)(configDir2, "tools");
7056
- (0, import_node_fs11.mkdirSync)(target, { recursive: true });
7228
+ const target = (0, import_node_path14.join)(configDir2, "tools");
7229
+ (0, import_node_fs12.mkdirSync)(target, { recursive: true });
7057
7230
  try {
7058
- (0, import_node_fs11.cpSync)((0, import_node_path13.join)(assets, "tools"), target, { recursive: true });
7231
+ (0, import_node_fs12.cpSync)((0, import_node_path14.join)(assets, "tools"), target, { recursive: true });
7059
7232
  return true;
7060
7233
  } catch {
7061
7234
  return false;
@@ -7125,6 +7298,14 @@ function managedServerLine(backend) {
7125
7298
  const ownApi = backend.serverMode === "byo" ? "Server: the owner connected **an API of their own** \u2014 `/api/*` on the app's domain is forwarded to it, and the platform adds no secret and authorizes nobody. " : "";
7126
7299
  return `${ownApi}Managed server: **not switched on** in the Backend tab. **Action files you push are stored, but every call to them answers \`404 server_not_enabled\`** until the owner switches the managed server on (\`agentful backend\` opens the tab; the CLI can enable the database, NOT the server). Switching on takes effect on the next call. Never tell the user an action works before the server is on.`;
7127
7300
  }
7301
+ function buildEnvList() {
7302
+ const entries = contract_default.publish.build_env.injected;
7303
+ const when = {
7304
+ always: "always",
7305
+ byo_supabase: "only while the owner's own Supabase database is connected in the Backend tab"
7306
+ };
7307
+ return entries.map((e) => ` - ${e.prefixes.map((p) => `\`${p}${e.suffix}\``).join(", ")} \u2014 ${when[e.when] ?? e.when}`).join("\n");
7308
+ }
7128
7309
  var bytes = (n) => n >= 1024 * 1024 ? `${n / (1024 * 1024)} MB` : `${n / 1024} KB`;
7129
7310
  function serverSideSection() {
7130
7311
  const tracks = contract_default.server_side;
@@ -7180,18 +7361,26 @@ is fine \u2014 but then say clearly that \`agentful push\` cannot deploy it.
7180
7361
  Server-side logic has its own place \u2014 see "Where server logic belongs".
7181
7362
  - The canonical build output is a root-level \`index.html\` inside the
7182
7363
  framework's output directory.
7183
- - There are **no build-time env vars**: the cloud build injects nothing beyond
7184
- NODE_ENV. There is no settings tab for build secrets \u2014 do not invent one.
7185
- Backend-tab secrets are runtime-only for Managed Server Actions and never
7186
- reach a build; the workspace executor injects only allowlisted PUBLIC client
7187
- vars (\`database.anon_key\`/\`publishable_key\` expanded as
7188
- \`VITE_\`/\`PUBLIC_\`/\`NEXT_PUBLIC_\`). Never rely on \`.env*\` files reaching the
7189
- cloud \u2014 the CLI and server both strip them.
7190
- - So PUBLIC client values go into an ordinary source constant, not into
7191
- \`import.meta.env\`/\`process.env\`: the project ID (below) and, when the app
7192
- talks to the owner's own Supabase, its project URL and **publishable (anon)
7193
- key**. In an \`agentful push\` build \`import.meta.env.VITE_SUPABASE_URL\` is
7194
- \`undefined\` \u2014 the app would compile and then fail to reach its database.
7364
+ - You **cannot set build-time env vars**, and there is no settings tab for
7365
+ build secrets \u2014 do not invent one. Backend-tab secrets are runtime-only for
7366
+ Managed Server Actions and never reach a build. Every cloud build \u2014 the
7367
+ workspace build and the \`agentful push\` build alike \u2014 defines the same
7368
+ fixed allowlist of PUBLIC client values, and nothing else:
7369
+ ${buildEnvList()}
7370
+ Never rely on \`.env*\` files reaching the cloud \u2014 the CLI never uploads
7371
+ them.
7372
+ - A cloud build **rejects** a read of one of those names while nothing defines
7373
+ it (e.g. \`import.meta.env.VITE_SUPABASE_URL\` with no own Supabase database
7374
+ connected): it would compile to \`undefined\` without any build error and the
7375
+ app would fail to reach its database. A read of any OTHER
7376
+ \`VITE_\`/\`PUBLIC_\`/\`NEXT_PUBLIC_\` name is never defined by the platform; the
7377
+ build warns and inlines \`undefined\`.
7378
+ - So PUBLIC client values of your own go into an ordinary source constant, not
7379
+ into \`import.meta.env\`/\`process.env\`. For the project ID (below) and, when
7380
+ the app talks to the owner's own Supabase, its project URL and **publishable
7381
+ (anon) key**, a source constant is also the form that works in local
7382
+ development: on this machine nothing defines the names above unless the user
7383
+ keeps a local \`.env\`, which never leaves it.
7195
7384
  Never put a Supabase **secret / service-role key** in source: that one
7196
7385
  belongs in Backend-tab secrets and is only readable from a Managed Server
7197
7386
  Action.
@@ -7342,7 +7531,7 @@ var CLOUD_AGENT_PROMPT = `You are the Agentful Cloud build agent: everything you
7342
7531
  var LOCAL_AGENT_PROMPT = `You are in local-only mode: free local development with no cloud deployment target \u2014 any stack is fine. Be clear that this directory will NOT be deployable with \`agentful push\` unless it satisfies the platform contract in AGENTFUL_CLOUD.md (${SERVER_SIDE_IN_ONE_SENTENCE}). If the user asks to deploy or push, check the contract first and say honestly whether the project can ship on the platform; never claim cloud deployability without evidence.`;
7343
7532
  function readAuthFile(path) {
7344
7533
  try {
7345
- return (0, import_node_fs12.readFileSync)(path, "utf8");
7534
+ return (0, import_node_fs13.readFileSync)(path, "utf8");
7346
7535
  } catch {
7347
7536
  return null;
7348
7537
  }
@@ -7407,11 +7596,11 @@ function buildEngineConfig(opts) {
7407
7596
  };
7408
7597
  }
7409
7598
  function engineXdg() {
7410
- const root = (0, import_node_path14.join)((0, import_node_os5.homedir)(), ".local", "share", "agentful");
7599
+ const root = (0, import_node_path15.join)((0, import_node_os5.homedir)(), ".local", "share", "agentful");
7411
7600
  return {
7412
- configHome: (0, import_node_path14.join)(root, "engine-config"),
7413
- dataHome: (0, import_node_path14.join)(root, "engine-data"),
7414
- stateHome: (0, import_node_path14.join)(root, "engine-state")
7601
+ configHome: (0, import_node_path15.join)(root, "engine-config"),
7602
+ dataHome: (0, import_node_path15.join)(root, "engine-data"),
7603
+ stateHome: (0, import_node_path15.join)(root, "engine-state")
7415
7604
  };
7416
7605
  }
7417
7606
  async function resolveTheme() {
@@ -7430,24 +7619,24 @@ async function resolveTheme() {
7430
7619
  }
7431
7620
  async function writeEngineSession(session, catalog, localProviders = {}, framework = "unknown", skillsInstruction = null, backendState, model) {
7432
7621
  const xdg = engineXdg();
7433
- const configDir2 = (0, import_node_path14.join)(xdg.configHome, "opencode");
7434
- const dataDir = (0, import_node_path14.join)(xdg.dataHome, "opencode");
7435
- const themesDir = (0, import_node_path14.join)(configDir2, "themes");
7436
- const pluginsDir = (0, import_node_path14.join)(configDir2, "plugins");
7437
- const commandsDir = (0, import_node_path14.join)(configDir2, "commands");
7438
- for (const dir of [themesDir, pluginsDir, commandsDir, dataDir, (0, import_node_path14.join)(xdg.stateHome, "opencode")]) {
7439
- (0, import_node_fs12.mkdirSync)(dir, { recursive: true });
7622
+ const configDir2 = (0, import_node_path15.join)(xdg.configHome, "opencode");
7623
+ const dataDir = (0, import_node_path15.join)(xdg.dataHome, "opencode");
7624
+ const themesDir = (0, import_node_path15.join)(configDir2, "themes");
7625
+ const pluginsDir = (0, import_node_path15.join)(configDir2, "plugins");
7626
+ const commandsDir = (0, import_node_path15.join)(configDir2, "commands");
7627
+ for (const dir of [themesDir, pluginsDir, commandsDir, dataDir, (0, import_node_path15.join)(xdg.stateHome, "opencode")]) {
7628
+ (0, import_node_fs13.mkdirSync)(dir, { recursive: true });
7440
7629
  }
7441
7630
  const imagegenAvailable = installEngineTools(configDir2) && session.img_on !== false;
7442
- const cloudInstructionsPath = (0, import_node_path14.join)(configDir2, CLOUD_INSTRUCTIONS_FILENAME);
7443
- (0, import_node_fs12.writeFileSync)(cloudInstructionsPath, renderCloudInstructions(framework, backendState));
7444
- const skillsPath = (0, import_node_path14.join)(configDir2, "AGENTFUL_SKILLS.md");
7631
+ const cloudInstructionsPath = (0, import_node_path15.join)(configDir2, CLOUD_INSTRUCTIONS_FILENAME);
7632
+ (0, import_node_fs13.writeFileSync)(cloudInstructionsPath, renderCloudInstructions(framework, backendState));
7633
+ const skillsPath = (0, import_node_path15.join)(configDir2, "AGENTFUL_SKILLS.md");
7445
7634
  const instructionPaths = [cloudInstructionsPath];
7446
7635
  if (skillsInstruction) {
7447
- (0, import_node_fs12.writeFileSync)(skillsPath, skillsInstruction);
7636
+ (0, import_node_fs13.writeFileSync)(skillsPath, skillsInstruction);
7448
7637
  instructionPaths.push(skillsPath);
7449
7638
  } else {
7450
- (0, import_node_fs12.rmSync)(skillsPath, { force: true });
7639
+ (0, import_node_fs13.rmSync)(skillsPath, { force: true });
7451
7640
  }
7452
7641
  const config = buildEngineConfig({
7453
7642
  session,
@@ -7457,13 +7646,13 @@ async function writeEngineSession(session, catalog, localProviders = {}, framewo
7457
7646
  instructionPaths,
7458
7647
  model
7459
7648
  });
7460
- (0, import_node_fs12.writeFileSync)((0, import_node_path14.join)(configDir2, "config.json"), JSON.stringify(config, null, 2));
7461
- const authPath2 = (0, import_node_path14.join)(dataDir, "auth.json");
7462
- (0, import_node_fs12.writeFileSync)(authPath2, mergedAuthJson(readAuthFile(authPath2), session.token));
7649
+ (0, import_node_fs13.writeFileSync)((0, import_node_path15.join)(configDir2, "config.json"), JSON.stringify(config, null, 2));
7650
+ const authPath2 = (0, import_node_path15.join)(dataDir, "auth.json");
7651
+ (0, import_node_fs13.writeFileSync)(authPath2, mergedAuthJson(readAuthFile(authPath2), session.token));
7463
7652
  const { brandingPluginSource: brandingPluginSource2 } = await Promise.resolve().then(() => (init_plugin(), plugin_exports));
7464
- const pluginPath = (0, import_node_path14.join)(pluginsDir, "agentful-branding.tsx");
7465
- (0, import_node_fs12.writeFileSync)(pluginPath, brandingPluginSource2());
7466
- (0, import_node_fs12.writeFileSync)((0, import_node_path14.join)(configDir2, "tui.json"), JSON.stringify({
7653
+ const pluginPath = (0, import_node_path15.join)(pluginsDir, "agentful-branding.tsx");
7654
+ (0, import_node_fs13.writeFileSync)(pluginPath, brandingPluginSource2());
7655
+ (0, import_node_fs13.writeFileSync)((0, import_node_path15.join)(configDir2, "tui.json"), JSON.stringify({
7467
7656
  theme: "agentful",
7468
7657
  plugin: [`file://${pluginPath}`],
7469
7658
  // The engine's own sidebar sections are plugins with fixed ids, and
@@ -7475,13 +7664,13 @@ async function writeEngineSession(session, catalog, localProviders = {}, framewo
7475
7664
  // state over this map.
7476
7665
  plugin_enabled: DISABLED_ENGINE_PLUGINS
7477
7666
  }, null, 2));
7478
- (0, import_node_fs12.writeFileSync)((0, import_node_path14.join)(themesDir, "agentful.json"), JSON.stringify(await resolveTheme(), null, 2));
7667
+ (0, import_node_fs13.writeFileSync)((0, import_node_path15.join)(themesDir, "agentful.json"), JSON.stringify(await resolveTheme(), null, 2));
7479
7668
  for (const name of Object.keys(SLASH_COMMANDS)) {
7480
- (0, import_node_fs12.writeFileSync)((0, import_node_path14.join)(commandsDir, `${name}.md`), commandMarkdown(name));
7669
+ (0, import_node_fs13.writeFileSync)((0, import_node_path15.join)(commandsDir, `${name}.md`), commandMarkdown(name));
7481
7670
  }
7482
7671
  for (const legacy of ["xdg-config", "xdg-data", "xdg-state"]) {
7483
7672
  try {
7484
- (0, import_node_fs12.rmSync)((0, import_node_path14.join)((0, import_node_os5.homedir)(), ".local", "share", "agentful", legacy), { recursive: true, force: true });
7673
+ (0, import_node_fs13.rmSync)((0, import_node_path15.join)((0, import_node_os5.homedir)(), ".local", "share", "agentful", legacy), { recursive: true, force: true });
7485
7674
  } catch {
7486
7675
  }
7487
7676
  }
@@ -7489,8 +7678,8 @@ async function writeEngineSession(session, catalog, localProviders = {}, framewo
7489
7678
  }
7490
7679
 
7491
7680
  // src/lib/skills.ts
7492
- var import_node_fs13 = require("fs");
7493
- var import_node_path15 = require("path");
7681
+ var import_node_fs14 = require("fs");
7682
+ var import_node_path16 = require("path");
7494
7683
 
7495
7684
  // src/generated/skills.json
7496
7685
  var skills_default = {
@@ -7498,7 +7687,7 @@ var skills_default = {
7498
7687
  schema_version: 1,
7499
7688
  skills: {
7500
7689
  "agentful-template-contract": "---\nname: agentful-template-contract\ndescription: Agentful generated-project and template contract for static preview/publish compatibility, scaffold-safe file structure, host-agnostic assets, backend honesty, and no unsupported dependencies. Use when creating a new project, customizing a scaffold/template, editing generated app structure, or fixing preview/publish issues.\n---\n\n# Agentful Template Contract\n\n## Contract\n\nGenerate and edit projects for Agentful's existing platform:\n\n- Static preview and publish serve built files from S3/CloudFront/Cloudflare.\n- Package projects must build to `dist/`, `out/`, or `build/`.\n- Vanilla projects must work without a package install or build step.\n- Managed database and managed actions are configured in the Backend tab, not invented in code.\n\nDo not add server-first framework runtime dependencies (for example Remix / `@remix-run/*`, or a Next.js server). A referenced example repo may inform structure or styling, but this platform serves static builds and does not run those servers.\n\n## File Shape\n\n- Preserve the current stack and file layout unless the user explicitly asks for a migration.\n- For empty workspaces, follow the loaded scaffold skill exactly.\n- Put route- or feature-local UI near the feature. Create shared folders only after real reuse exists.\n- Avoid dumping grounds such as `helpers`, `misc`, or broad `lib` folders when ownership is clear.\n- Keep generated documentation short and accurate; do not describe features that do not exist.\n\n## Hosting Rules\n\n- Asset paths for project-owned files: SPAs with a client-side (history-mode)\n router use root-absolute paths (`base: '/'`, `/assets/...`); router-less or\n multi-page projects use relative paths.\n- Use `/api/...` only for platform runtime APIs.\n- Do not hardcode `mainmvp.com`, `agentful.dev`, preview domains, or user subdomains into app code.\n- Do not add a `<base>` tag.\n- For canonical, Open Graph, sitemap, and manifest URLs, use relative URLs or omit the origin.\n- For SPAs on static hosting, use history-mode routing with `base: '/'` (never\n hash routing) \u2014 the platform falls unknown deep-links back to `index.html`, so\n routes resolve on hard refresh with clean URLs (no `#`).\n\n## Backend Honesty\n\n- When a backend is configured (`status: active`), use it for real data, auth, and actions.\n- When no backend is configured, still build the requested UI from clearly-labeled sample/demo data (one replaceable module). Do not refuse or stop \u2014 surface the Backend-tab note in your final response instead.\n- Sample data must read as sample. Do NOT fake auth that \"logs in\", saves that claim to persist across reloads, or payment/webhook flows that pretend to fire \u2014 those mislead the user. Presentation data (example metrics, sample listings) is fine; a fake real-backend contract is not.\n- Never store secret keys in frontend code, templates, `.env`, or committed files.\n- Public client keys may be placeholders only when the target integration actually uses public keys.\n\n## Content Honesty\n\nDo not invent:\n\n- customer logos, testimonials, reviews, awards, revenue, user counts, certifications, compliance claims, or legal assurances\n- real prices, policies, medical/financial claims, or guarantees unless the user supplies them\n\nUse neutral placeholder copy or proof-ready sections instead.\n\n## Before Finishing\n\n- Verify imports, references, asset paths, and routes are defined.\n- Run the relevant build when a build script exists.\n- Confirm the expected output folder contains an `index.html`.\n- For UI work, include responsive behavior and basic loading, empty, error, and success states where the feature implies them.\n",
7501
- "agentful-managed-db": "---\nname: agentful-managed-db\ndescription: Managed Database protocol for `database.mode == managed`. Covers schema upsert, CRUD against `/api/p/{PROJECT_ID}/data/*` and `/api/p/{PROJECT_ID}/auth/*`, error-code remediation, and per-framework client patterns (Vue, React, Svelte, SvelteKit, Astro, Vanilla). Load when the backend preamble shows `Database: managed`.\n---\n\n## When To Use\n\nLoad this skill **only** when `agentful-backend-state` reports `database.mode: \"managed\"` with `status: \"active\"`. Do not load it for `byo` (Supabase / custom server) or when the database is not configured.\n\n## Hard Rules\n\n1. **Collections do not auto-create.** Writes to a non-existent collection return 404 with `error.code: not_found`. For every collection your code reads or writes, if it is not listed in the backend preamble's `Managed collections` block, you MUST run `agentful-managed-collections upsert <project_id> '<json>'` BEFORE writing the code that touches it.\n2. **Never wrap data-API calls in a swallow-all `try/catch`.** Swallowing masks 4xx errors and produces apps that look-fine-but-write-nothing.\n3. **Never set a `seeded` flag unless every write returned 201.** Partial-success seeds drift state silently.\n4. **Do not call this a \"server\".** It is a managed database behind a gateway. Use \"the database\" when talking to the user.\n5. **Do not target `/data/_collections`** from generated code. That's the owner-only schema endpoint; use the `agentful-managed-collections` CLI for schema work.\n6. **On 5xx, do not speculate.** Surface `error.correlation_id` to the user verbatim and stop. Do not invent internal causes (DynamoDB, operators, system collections, etc.).\n\n## Authoring Protocol \u2014 for every collection touch\n\nRun, in order:\n\n1. **Read the preamble.** The `[BACKEND STATUS]` block lists existing `Managed collections` with their fields and access rules. If your target collection is there with the right shape, skip to step 3.\n2. **Upsert if missing or schema mismatch:**\n ```\n agentful-managed-collections upsert <project_id> '{\"name\":\"todos\",\"access_rule\":\"owner\",\"schema\":{\"fields\":[{\"name\":\"title\",\"type\":\"string\",\"required\":true},{\"name\":\"done\",\"type\":\"boolean\"}]}}'\n ```\n Field types: `string`, `text`, `number`, `boolean`, `select` (with `options`), `date` (ISO 8601 calendar date `YYYY-MM-DD`), `datetime` (ISO 8601 string), `json` (object or array). Any other type is rejected by the platform (400 with the allowed list).\n Access rules: `public` (anyone), `authenticated` (any logged-in end-user), `owner` (only `created_by` user), `admin` (only end-users whose `_users.role` is `admin`).\n Every successful upsert is also DECLARED: the command mirrors the collection (as the platform stored it) into `.agentful/backend.json` in the workspace and answers `\"declaration\": \".agentful/backend.json\"`. That file is the project's backend declaration \u2014 it travels with the code (a local `agentful push` applies it again, idempotently) and the Backend tab shows it. Do not edit it to claim a collection exists; the upsert output is the only confirmation. Never commit it to `.gitignore`.\n3. **Write the client code** using the patterns below. Use the EXACT field names from the schema. Do not invent fields.\n4. **Test the happy path** by inspecting the response. Real 201 / 200, not a swallowed error.\n\n## Choosing An Access Rule\n\n`access_rule` is set per collection at upsert time and enforced on every end-user\n(`/data/*`) request. There are exactly four rules \u2014 pick by use case:\n\n| Use case | Rule | Why |\n|---|---|---|\n| Content anyone may read/write without login (public poll, guestbook) | `public` | No JWT required. |\n| Public content the app seeds once and the UI only reads | `public` | Seed at build time; clients read only. |\n| Shared data **every** logged-in user may read AND edit (team wiki, shared catalog) | `authenticated` | Any valid end-user JWT passes. **No per-row owner check.** |\n| Per-user private data (todos, drafts, a user's own orders) | `owner` | Only the `created_by` end-user can read/update/delete each doc. |\n| Data only the app's admins may read/write (moderation queues, settings) | `admin` | Only end-users whose `_users.role` is `admin` (owner-set, see First Admin below). |\n| Per-user data an **admin must also access** (invoices, tickets, client records) | `owner` + admin via Action | `owner` protects the client; admin reads/writes through a Managed Action. See RBAC below. |\n| A field only the server may set (`role`, `plan`, `verified`, `balance`) | `owner`, with that field written **only** via an Action | No field-level rules exist \u2014 gate the whole mutation behind an Action. |\n\n**Two traps to design around:**\n\n1. **`authenticated` is NOT per-user isolation.** It means *every* logged-in\n user can read and write *all* documents in the collection. For \"each user\n sees only their own\", use `owner`.\n2. **There is no combined `owner_or_admin` rule and no role concept in the data\n layer.** A multi-role portal (admin / member / client) cannot be expressed by\n `access_rule` alone. The supported pattern is `owner` + a Managed Action that\n verifies the caller \u2014 see **RBAC & Secure Role Assignment** under Managed\n Actions. (Requires `server.mode == managed`.)\n\n## API Surface\n\nBase: `/api/p/{PROJECT_ID}/`\n\n**Auth (end-user):**\n- `POST auth/register` \u2014 `{email, password, display_name?}`. Two response shapes:\n - Verification pipeline ACTIVE (project has `config.auth`, default): `201 {verification_required:true, user:{...}}` \u2014 **NO token yet**; the user must confirm their email first (mail is sent automatically).\n - Legacy project (no `config.auth`) or `require_verified_login:false`: `201 {token, verification_required:false, user:{...}}`.\n- `POST auth/login` \u2014 `{email, password}` \u2192 `{token, user:{...}}`. Blocks with `403 email_unverified` when the project requires verified logins and the account is not verified yet \u2192 show a \"check your inbox\" state with a resend button.\n- `GET auth/me` \u2014 Bearer token \u2192 `{user:{...}}`\n- `POST auth/verify` \u2014 `{uid, token}` (from the mail link) \u2192 `{token, user}` (auto-login after verification).\n- `POST auth/resend-verification` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/request-password-reset` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/reset-password` \u2014 `{uid, token, password}` \u2192 `200 {reset:true}`. Also marks the mailbox verified.\n\n`user` shape: `{id, email, display_name, role, verified, provider, created_at}`.\n\n**Auth mail links:** verification/reset mails link to the deployed app as\n`{app_url}/?ta_action=verify&uid=\u2026&token=\u2026` and `{app_url}/?ta_action=reset&uid=\u2026&token=\u2026`.\n**Every generated app with auth MUST handle these two query params on load**\n(see Client Patterns).\n\n**Login methods governance:** offer ONLY the login methods the project's\n`config.auth.methods` allows (check with `agentful-auth-config get`;\ndefault `[\"email\"]`). Do NOT generate \"Sign in with Google\"/SSO buttons unless\n`google`/`oidc` is listed \u2014 the platform refuses unlisted methods server-side.\n\n**Google login (when `google` IS listed):** a \"Continue with Google\" button\ncalls `googleAuth.start()` (see Client Patterns) \u2192 central broker\n`api.agentful.dev/auth/oauth/google/start` \u2192 Google \u2192 back to the app with the\nJWT in the URL fragment; call `handleGoogleReturn()` at startup to complete\nthe login. Google users arrive `verified: true` (Google verified the mailbox),\nexisting email accounts with the same address are linked automatically, and\nthe `admin_email` bootstrap applies. Show `auth_error` codes as a friendly\nmessage (`auth_method_not_allowed` \u2192 \"Google login is not available for this\napp\"); never retry in a loop.\n\n**Data:**\n- `GET data/{collection}` \u2014 list (paginated; `?limit=`, `?cursor=`)\n- `GET data/{collection}/{docId}` \u2014 single doc\n- `POST data/{collection}` \u2014 body `{data: {...}}` \u2192 `{ok:true, data:{doc_id, collection, data, created_at}}`\n- `PUT data/{collection}/{docId}` \u2014 body `{data: {...}}` \u2192 updated doc\n- `DELETE data/{collection}/{docId}` \u2192 `{ok:true, data:{deleted, collection}}`\n\nEnd-user routes (above) require `Authorization: Bearer <token>` from `auth/register` or `auth/login`. The collection's `access_rule` enforces what each token may read/write.\n\n## Response Shapes\n\n**Success:** `{ \"ok\": true, \"data\": {...} }` \u2014 `data` for lists has `{documents:[...], count, cursor}`.\n\n**Error:** `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"correlation_id\"?: \"...\" } }`\n\nStatus codes are HTTP-conventional (201 on create, 200 on read/update/delete, 4xx for client errors, 5xx for platform).\n\n**Actions are the one exception:** `POST \u2026/actions/{name}` returns `{ \"ok\": true, \"data\": <whatever your action returned> }` with HTTP 200 whenever the action *ran to completion*. The outer `ok` only says \"the platform executed it\" \u2014 your own `{ error: '\u2026' }` return lands verbatim inside `data`, it does NOT flip the outer `ok` and cannot set the HTTP status (only a thrown error becomes `ok:false` / `runtime_error` 500). The client must therefore check `body.data.error` after the envelope check (see `callAction` below).\n\n## Error Code \u2192 Remediation\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `not_found` | 404 | Collection doesn't exist, OR doc id doesn't exist | If collection: run `agentful-managed-collections upsert` then retry. If doc: surface to user. |\n| `auth_required` | 401 | `/data/*` on a non-`public` collection without a `Authorization: Bearer` token | Send the end-user to login; do not retry without a token. |\n| `invalid_token` | 401 | The Bearer token on `/data/*` is invalid or expired (`wrong_project` 403 = token from another project) | Clear the stored token and show the login state. |\n| `email_unverified` | 403 | Login blocked until the user confirms their email | Show \"confirm your email\" state + resend button (`auth/resend-verification`). |\n| `invalid_token` | 400 | Verification/reset link invalid, expired, or already used | Offer resend (`resend-verification`) or a new reset request. |\n| `too_many_attempts` | 429 | Auth rate limit hit (failed logins / mail requests) | Tell the user to wait a few minutes; do not auto-retry. |\n| `email_infra_unconfigured` | 409 | Project has no email infrastructure selected | Tell the BUILDER (not the end-user): choose \"Agentful Email\" or BYO in project settings \u2192 Email infrastructure. |\n| `email_delivery_unavailable` | 409 | The app has no published URL yet \u2014 verification/reset mails need a deployed app to link to (the preview URL does not count) | Platform state, not a code bug: tell the BUILDER to publish the app; show the end-user \"sign-up by email is not available yet\". Google login works on the preview already. Never treat as transient; never retry in a loop. |\n| `email_send_failed` | 502 | Mail transport failed transiently | Tell the user to try again later. |\n| `schema_validation_failed` | 400 | Payload doesn't match the collection's schema | Re-read the schema in the preamble; fix field names/types/required-ness; do not retry blindly. |\n| `readonly_collection` | 403 | Collection or doc is system-protected (e.g. `_users` via end-user route) | Use the correct route (e.g. `auth/register` for `_users`); do not retry. |\n| `forbidden` | 403 | Access rule denied this end-user | Tell the user they need to log in / lack permission. |\n| `already_exists` | 409 | `doc_id` collision or conditional check failed | Let `doc_id` auto-generate (omit it). |\n| `too_large` | 413 | Document > 256 KB | Split or trim payload. |\n| `quota_exceeded` | 429 | DDB throttling / request-limit spillover (transient) | The doctor returns `action: retry_with_backoff`. Sleep + retry up to 3 times per `details.backoff_ms`. **Surface `correlation_id` to the user ONLY after all attempts exhaust.** |\n| `transient_storage_error` | 503 | DDB internal / service-unavailable (transient) | Same as `quota_exceeded`: doctor returns `retry_with_backoff`; engine handles silently until budget exhausts. |\n| `internal_storage_error` / `write_failed` / `delete_failed` | 500 | Platform-side error, NOT classified retryable | Delegate to `@agentful-managed-db-doctor` with `correlation_id`. Surface its `user_message` verbatim. Do NOT retry in a loop. Do NOT invent a root cause. |\n\n## Client Patterns\n\nA tiny client used everywhere. Define once per project; reuse for all collections.\n\n### Vanilla / shared base\n\n```js\n// src/lib/data.js\nconst BASE = `/api/p/${PROJECT_ID}`; // set PROJECT_ID at build time\nconst tokenKey = 'mm_auth_token';\n\nfunction authHeader() {\n const t = localStorage.getItem(tokenKey);\n return t ? { Authorization: `Bearer ${t}` } : {};\n}\n\nasync function jsonOrThrow(res) {\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.ok) {\n const err = new Error(body.error?.message || `HTTP ${res.status}`);\n err.code = body.error?.code;\n err.correlation_id = body.error?.correlation_id;\n err.status = res.status;\n throw err;\n }\n return body.data;\n}\n\nexport const auth = {\n async register(email, password, display_name) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/register`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password, display_name }),\n }));\n // verification_required \u2192 NO token yet; caller must show \"check your inbox\".\n if (data.token) localStorage.setItem(tokenKey, data.token);\n return data; // {verification_required, user, token?}\n },\n async login(email, password) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/login`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n }));\n localStorage.setItem(tokenKey, data.token);\n return data.user;\n },\n async verify(uid, token) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/verify`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token }),\n }));\n localStorage.setItem(tokenKey, data.token); // auto-login after verify\n return data.user;\n },\n async resendVerification(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/resend-verification`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async requestPasswordReset(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/request-password-reset`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async resetPassword(uid, token, password) {\n return jsonOrThrow(await fetch(`${BASE}/auth/reset-password`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token, password }),\n }));\n },\n logout() { localStorage.removeItem(tokenKey); },\n token() { return localStorage.getItem(tokenKey); },\n};\n\n// Google login (ONLY when 'google' \u2208 config.auth.methods \u2014 never generate\n// this button otherwise; the platform refuses unlisted methods server-side).\n// Redirects to the central Agentful OAuth broker; after Google consent the\n// broker 302s back to `redirect` with the app JWT in the URL FRAGMENT:\n// https://<your-app>/#token=<jwt>&provider=google (or #auth_error=<code>)\nexport const googleAuth = {\n start(redirect = location.origin + '/') {\n const url = new URL('https://api.agentful.dev/auth/oauth/google/start');\n url.searchParams.set('project_id', PROJECT_ID);\n url.searchParams.set('redirect', redirect); // must be THIS app's https origin\n location.href = url.toString();\n },\n};\n\n// REQUIRED whenever the Google button is generated: pick up the broker return\n// on app load (fragment token \u2192 login; auth_error \u2192 user-visible message).\nexport function handleGoogleReturn() {\n const h = new URLSearchParams(location.hash.slice(1));\n const token = h.get('token'), err = h.get('auth_error');\n if (!token && !err) return null;\n history.replaceState(null, '', location.pathname + location.search); // strip token from URL\n if (err) return { ok: false, error: err }; // e.g. auth_method_not_allowed, oauth_failed\n localStorage.setItem(tokenKey, token);\n return { ok: true, provider: h.get('provider') || 'google' };\n}\n\n// REQUIRED in every app with auth: handle the mail links on app load.\n// Call once at startup (before router init is fine).\nexport async function handleAuthMailAction() {\n const p = new URLSearchParams(location.search);\n const action = p.get('ta_action'), uid = p.get('uid'), token = p.get('token');\n if (!action || !uid || !token) return null;\n history.replaceState(null, '', location.pathname); // strip token from URL\n if (action === 'verify') {\n try { const user = await auth.verify(uid, token); return { action, ok: true, user }; }\n catch (e) { return { action, ok: false, error: e.code || 'invalid_token' }; }\n }\n if (action === 'reset') return { action, ok: true, uid, token }; // show new-password form, then auth.resetPassword(uid, token, pw)\n return null;\n}\n\nexport const data = {\n async list(collection, opts = {}) {\n const qs = new URLSearchParams(opts).toString();\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}${qs ? '?' + qs : ''}`, {\n headers: { ...authHeader() },\n }));\n },\n async get(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n headers: { ...authHeader() },\n }));\n },\n async create(collection, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async update(collection, docId, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async remove(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'DELETE',\n headers: { ...authHeader() },\n }));\n },\n};\n```\n\n### React / Vue / Svelte\n\nUse the same `data.js` / `data.ts` module above; wrap in framework-native state primitives.\n\n- **React:** call from `useEffect` for reads, `useState` for results; surface `err.code`/`err.correlation_id` to the user when caught. Do not put data calls in render bodies.\n- **Vue:** use `onMounted` for reads and a `ref()` for results; same error surfacing.\n- **Svelte / SvelteKit:** call from `onMount` (or a `load` function in SvelteKit); SvelteKit static-adapter projects must NOT use server `load` (no Node runtime in static deploy).\n- **Astro:** call only from client-side islands; no server fetch (static-only build).\n\n### TypeScript types\n\nGenerate the per-collection type from the preamble's field list:\n\n```ts\n// Example for a collection with fields: title:string*, done:boolean\ntype Todo = { title: string; done?: boolean };\n\n// And response wrappers:\ntype DataDoc<T> = { doc_id: string; collection: string; data: T; created_at: string };\ntype DataList<T> = { documents: DataDoc<T>[]; count: number; cursor?: string };\n```\n\n## Anti-patterns\n\n- \u274C `try { await data.create(...) } catch { /* ignore */ }` \u2014 masks failures.\n- \u274C Hardcoding `doc_id` for \"convenience\" \u2014 causes `already_exists` 409 on retry.\n- \u274C Writing to a collection name that doesn't appear in the preamble \u2014 `not_found` 404.\n- \u274C Using the schema endpoint `/data/_collections` from client code \u2014 owner-only, end-users get 403.\n- \u274C Storing the JWT anywhere other than `localStorage` under a project-scoped key; do not put it in cookies (CORS) or in `sessionStorage` (lost on tab close).\n- \u274C Telling the user \"the database is down\" because of a 5xx. Surface the `correlation_id` and stop.\n- \u274C Surfacing `correlation_id` on transient errors (`quota_exceeded`, `transient_storage_error`) before the doctor's `retry_with_backoff` budget is exhausted. The whole point is the user sees nothing while the retry loop is in play; only escalate after all `max_attempts` fail.\n\n## Diagnostic delegation\n\nIf you hit a 5xx that doesn't map to a retry-able 4xx in the table above, do not diagnose yourself. Delegate to `@agentful-managed-db-doctor` (added in PR 1.3) with the `correlation_id` from the response. The doctor has constrained tools and cannot fabricate platform internals.\n\n---\n\n## Managed Actions (only when `server.mode == managed`)\n\nManaged Actions are small Node.js functions the platform runs for the project at `/api/p/{PROJECT_ID}/actions/{name}`. Use them when a frontend operation needs (a) a secret API key, (b) a non-public/secured external API, or (c) server-enforced trust (price computation, admin actions). For pure CRUD against the managed DB, use the data API directly; do NOT route everything through an action.\n\n**What an action is NOT.** It is a request/response function: JSON in, JSON out, and it only runs when something calls it. There is **no scheduler** (no cron, no background jobs, no queues), no sockets or streaming, and no way to verify a webhook signature (no `crypto`, and the raw request body is not available \u2014 a body that is not valid JSON silently arrives as `{}`, so form-encoded webhooks such as Twilio or Slack cannot be read at all). Do not promise any of these; \"Two classes of actions\" below has the honest ways out.\n\n### Hard rules (Managed Actions)\n\n1. **Upsert FIRST, fetch SECOND.** If your generated code calls `fetch('/api/p/.../actions/{name}')`, you MUST upsert the action via the CLI BEFORE writing the fetch call. An undeployed action returns `404 action_not_found`; the preamble's `Managed actions` list is the ground truth for what exists.\n2. **The sandbox has NO module system.** No `require` of any kind (incl. `module.require`), no `import` / dynamic `import()`, no npm packages, no Node.js core modules (not even `crypto`), no `process` and no environment variables. \"Node.js 20\" is the host the platform runs actions on, NOT your action's API. An action is a plain script whose full surface is `ctx.fetch`, `ctx.data`, `ctx.secrets`, `ctx.user`, `ctx.body` plus these globals: `JSON`, `Math`, `Date`, `URL`/`URLSearchParams`, `Buffer`, `TextEncoder`/`TextDecoder`, `atob`/`btoa`, `AbortController`/`AbortSignal`, `Promise`, `RegExp`, standard primitives and `console`. There is deliberately no crypto primitive in sandbox code \u2014 key handling and cryptography live in the trusted platform host (`ctx.secrets`), never in generated action code; do not probe for one. The validator rejects module-system usage at upload time with `{code: 'action_validation_failed', reason: 'require_unavailable' | 'dynamic_import_unavailable' | 'esm_not_supported' | 'process_unavailable'}`.\n3. **Secrets are read with `await ctx.secrets.get('<name>')` \u2014 never property access.** `ctx.secrets` has exactly one method, `get(key)`. `ctx.secrets.SOME_KEY` is silently `undefined`. Secret names must match `^(database|server)\\.[a-z][a-z0-9_]{0,126}$` \u2014 use `server.stripe_secret_key`, not `STRIPE_SECRET_KEY` (uppercase, unprefixed names cannot even be stored in Backend \u2192 Secrets).\n4. **Actions are publicly invokable \u2014 every action starts with a caller gate.** Nothing else gates who can call it. `ctx.user` is the JWT-verified end-user (`{ id, email, pid }`) or `null`. Any action that touches a secret, writes data, or returns non-public information must begin with exactly one of two gates: **user-authorized** (the default) \u2014 `if (!ctx.user) return { error: 'auth_required' }` before anything else; or **machine-triggered** \u2014 only for callers that cannot carry an app JWT (an external scheduler, a webhook sender), marked with `// machine-triggered: \u2026` as the FIRST line of the file, gated by a token from a request **header** compared against `ctx.secrets`, or by an authoritative look-up at the provider. An unmarked action without a `ctx.user` check is a bug. See \"Two classes of actions\".\n5. **Outbound calls from inside an action must use `ctx.fetch`**, not a bare `fetch`. `ctx.fetch` injects `X-Action-Depth` on self-action URLs so the platform's loop detector can break runaway recursion. External URLs are passed through unchanged.\n6. **Cost shape.** 5 s budget per action (10 s hard limit), 256 MB RAM, 1 MB request body, 256 KB action source, per-project concurrent invocations capped at 10. The 5 s are a planning figure: an action still waiting on `ctx.fetch`/`ctx.data` when the budget runs out is answered with `408 timeout`, but calls it already started may still complete \u2014 a 408 does not mean \"nothing happened\". Plan for `429 concurrency_exceeded` under load and surface it to the user gracefully (e.g. retry with backoff or \"try again in a moment\").\n\n### Action shape\n\n```js\n// .server/actions/checkout.js\nmodule.exports = async function (ctx) {\n // ctx.body \u2014 parsed JSON request body\n // ctx.user \u2014 JWT-verified end-user { id, email, pid }, or null if not logged in\n // ctx.data \u2014 same CRUD surface as the public data API, scoped to this project\n // ctx.secrets \u2014 async accessor for Backend \u2192 Secrets: await ctx.secrets.get('server.stripe_secret_key')\n // ctx.fetch \u2014 depth-aware fetch wrapper\n if (!ctx.user) return { error: 'auth_required' };\n const apiKey = await ctx.secrets.get('server.stripe_secret_key');\n if (!apiKey) return { error: 'missing_server_secret' };\n const stripeRes = await ctx.fetch('https://api.stripe.com/v1/checkout/sessions', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({ /* \u2026 */ }).toString(),\n });\n const session = await stripeRes.json();\n return { url: session.url };\n};\n```\n\n**What the caller receives.** The platform wraps the return value verbatim:\n`{ ok: true, data: { url } }` on success and \u2014 for the two early returns\nabove \u2014 `{ ok: true, data: { error: 'auth_required' } }`, still HTTP 200.\nKeep the `{ error: '<code>' }` convention for application-level failures\n(do not nest a second `{ok:false, error:{\u2026}}` envelope) and unwrap twice\nin the frontend:\n\n```js\n// src/lib/actions.js \u2014 one helper for every action call\nasync function callAction(name, payload) {\n const res = await fetch(`${BASE}/actions/${name}`, {\n method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify(payload ?? {}),\n });\n const result = await jsonOrThrow(res); // platform envelope: action_not_found, runtime_error, \u2026\n if (result && typeof result === 'object' && result.error) {\n const err = new Error(result.error); // the action's own { error: '<code>' }\n err.code = result.error;\n throw err;\n }\n return result;\n}\n```\n\n### Two classes of actions\n\nHard rule 4 knows two kinds of caller. Pick one per action and never mix them.\n\n| | User-authorized (default) | Machine-triggered |\n|---|---|---|\n| Caller | The app in the browser, end-user logged in | An external scheduler, a webhook sender, a receiver you put in front |\n| Mandatory gate (first statement) | `if (!ctx.user) return { error: 'auth_required' }` | A token from a request **header** compared against `await ctx.secrets.get('server.<name>')` \u2014 or an authoritative look-up at the provider |\n| File marker | none | first line `// machine-triggered: token gate` or `// machine-triggered: provider look-up` |\n| Failure convention | **Return** `{ error }` (HTTP 200, the app shows it) | Rejecting a caller: **return** `{ error }`. A transient failure on a genuine delivery: **throw** \u2192 HTTP 500, so the sender delivers again. A returned `{ error }` is a 200 and acknowledges an event you did not process |\n| Duties | \u2014 | Idempotent: deliveries repeat, and after a `408` the first run may still be finishing. `ctx.data` has **no conditional write**, so \"exactly once\" cannot be built \u2014 make a repeat end in the same state, and bind side effects (mail) to the state change |\n\nKeep thrown messages generic (`throw new Error('stripe_unavailable')`): the message of a thrown error is returned to the caller in the 500 body.\n\n#### Time-triggered work: an external scheduler calls a machine-triggered action\n\nThe platform has no scheduler. The honest way out: an external scheduler (GitHub Actions `schedule`, cron-job.org, the schedule feature of the provider the user already has) calls the action over HTTPS and sends a token in a header. Tell the user that THEY set up and own that scheduler; you only build its target.\n\n```js\n// machine-triggered: token gate\n// .server/actions/daily-digest.js \u2014 called by an EXTERNAL scheduler, never by the app\nfunction sameToken(given, expected) {\n // Constant-time compare as a plain XOR loop; the sandbox has no timing-safe helper.\n if (typeof given !== 'string' || given.length !== expected.length) return false;\n let diff = 0;\n for (let i = 0; i < expected.length; i++) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0;\n}\n\nmodule.exports = async function (ctx) {\n const expected = await ctx.secrets.get('server.scheduler_token');\n if (!expected || !sameToken(ctx.headers['x-scheduler-token'], expected)) {\n return { error: 'forbidden' }; // reject BEFORE any other secret is read or any data is touched\n }\n const mailKey = await ctx.secrets.get('server.resend_api_key');\n if (!mailKey) throw new Error('missing_server_secret');\n\n // One state document (collection `digest_state` must exist). A repeated call\n // on the same day finds the same state and sends nothing.\n const today = new Date().toISOString().slice(0, 10);\n const [state] = await ctx.data.list('digest_state', { limit: 1 });\n if (state && state.data.last_day === today) return { skipped: 'already_sent' };\n\n const res = await ctx.fetch('https://api.resend.com/emails', {\n method: 'POST',\n headers: { Authorization: `Bearer ${mailKey}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({ /* from, to, subject, html */ }),\n });\n if (!res.ok) throw new Error('mail_provider_unavailable'); // 500 \u2192 the scheduler retries\n\n if (state) await ctx.data.update('digest_state', state.doc_id, { ...state.data, last_day: today });\n else await ctx.data.create('digest_state', { last_day: today });\n return { sent: true };\n};\n```\n\nWhat the user configures in their scheduler (header names reach the action lower-cased in `ctx.headers`):\n\n```\ncurl -X POST https://<app-domain>/api/p/<project_id>/actions/daily-digest \\\n -H 'Content-Type: application/json' -H 'X-Scheduler-Token: <the value of server.scheduler_token>' -d '{}'\n```\n\nTwo overlapping calls can both pass the `last_day` check (no conditional write) \u2014 tell the user to schedule one call per period, and do not promise exactly-once delivery.\n\n**One run, many recipients: keep the state PER RECIPIENT.** The example above sends one mail, so one state document is enough. A run that mails every user must not throw at the end and leave a single \"sent today\" flag unset: the retry would mail everyone again who already got theirs. Record `last_day` per recipient right after that recipient's mail went out, skip recipients already marked for today, and throw at the end only so the scheduler retries the ones that failed. Recipients come from the app's own collection (see \"`ctx.data` Is Privileged\" \u2014 `_users` is not readable), and with more than 100 of them one call cannot cover everyone: say so instead of silently mailing the first hundred.\n\n#### Incoming webhooks: three ways, none equals signature verification\n\n1. **Authoritative look-up (restricted alternative, JSON webhooks only).** Take **only the event id** from the incoming body, fetch the event from the provider with the secret key, and work **exclusively with the fetched data**. This proves the EVENT is real \u2014 it does **not** prove the REQUEST came from the provider. Therefore also: check the assignment (order id from the metadata, amount, currency, status), tolerate duplicate delivery, and throw on a transient failure. It costs one provider API call per delivery, forged ones included; the per-project concurrency cap bounds that.\n2. **A receiver in front that verifies signatures** (the user's own Worker, Hookdeck, Pipedream, \u2026) and then calls a machine-triggered action with a header token. This is the only way with real signature verification \u2014 recommend it whenever money moves on the webhook alone.\n3. **A token in the URL is NOT a substitute.** It is a bearer secret that sits in logs and dashboards. At most an additional filter in front of way 1.\n\n```js\n// machine-triggered: provider look-up\n// .server/actions/stripe-webhook.js \u2014 Stripe calls this; there is NO signature check here\nmodule.exports = async function (ctx) {\n // 1. Take ONLY the event id from the incoming body; everything else in it is unauthenticated.\n const eventId = ctx.body && ctx.body.id;\n if (typeof eventId !== 'string' || !/^evt_[A-Za-z0-9]{1,100}$/.test(eventId)) return { error: 'bad_event' };\n\n // 2. Gate: fetch the event from Stripe. A made-up id does not exist there.\n const apiKey = await ctx.secrets.get('server.stripe_secret_key');\n if (!apiKey) throw new Error('missing_server_secret'); // 500 \u2192 Stripe redelivers once the key is stored\n const res = await ctx.fetch(`https://api.stripe.com/v1/events/${eventId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.status === 404) return { error: 'unknown_event' }; // reject: not a real event\n if (!res.ok) throw new Error('stripe_unavailable'); // transient \u2192 500 \u2192 Stripe redelivers\n const event = await res.json(); // from here on use ONLY `event`, never ctx.body\n\n if (event.type !== 'checkout.session.completed') return { ignored: event.type };\n const session = event.data.object;\n if (session.payment_status !== 'paid') return { ignored: 'not_paid' };\n\n // 3. Check the assignment: the order must exist and match what was charged.\n const orderId = session.metadata && session.metadata.order_id;\n const order = orderId ? await ctx.data.get('orders', orderId) : null;\n if (!order) return { error: 'unknown_order' };\n if (order.data.amount_cents !== session.amount_total || order.data.currency !== session.currency) {\n return { error: 'amount_mismatch' };\n }\n\n // 4. Idempotent: a second delivery finds the same state and changes nothing.\n // Side effects (confirmation mail) belong HERE, on the transition \u2014 never above this check.\n if (order.data.status === 'paid') return { already: true };\n await ctx.data.update('orders', order.doc_id, { ...order.data, status: 'paid', stripe_event: event.id });\n return { paid: true };\n};\n```\n\n**The assignment check fails CLOSED.** Compare strictly, exactly as above: a fetched event without `amount_total` or `currency`, or an order without a recorded amount, is a MISMATCH. Do not soften it \"defensively\" into `if (typeof session.amount_total === 'number' && \u2026)` \u2014 that makes the one check standing between a replayed event and a paid order optional.\n\n`ctx.data.update` REPLACES the document's `data` \u2014 always spread the current `data` into the update, as above. The checkout action that creates the Stripe session must put `order_id` into the session `metadata`; without it the webhook cannot check the assignment.\n\n### Authoring protocol (engine flow)\n\n1. Read the preamble's `Managed actions` block. If your target name is already deployed with the right shape, skip to step 3.\n2. **Upsert:** write the action source to a local temp file, then `agentful-managed-actions upsert <project_id> <name> <file_path>`. The validator runs on both the public PUT-files path and this CLI; same rejection rules.\n3. Write the frontend `fetch('/api/p/<project_id>/actions/<name>', { method: 'POST', body: JSON.stringify(payload) })`. Include `Content-Type: application/json` on POSTs.\n4. Test once with `agentful-managed-actions invoke <project_id> <name> --body '{\"\u2026\":\"\u2026\"}'` to confirm the deployment landed. **Read the result in two layers:** HTTP 200 + `{ok:true,data:\u2026}` only proves the action returned without throwing \u2014 the platform wraps whatever the action returns, so `data` may itself be `{error:'auth_required'}` or another app-level failure. An unauthenticated CLI invoke of a `ctx.user`-gated action therefore proves deployment only, never business success; the business path needs a real end-user JWT (log in through the app, or the platform E2E harness).\n\n### Action-invocation error codes (response body)\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `action_not_found` | 404 | The action with that name doesn't exist | Upsert it first. |\n| `action_validation_failed` | 400 | Validator rejected the source (size, filename, or require/import/process usage \u2014 the sandbox has no module system) | Fix per `reason` field; do not retry. |\n| `concurrency_exceeded` | 429 | Per-project cap reached | Backoff + retry, or surface to user. |\n| `action_loop_detected` | 508 | `X-Action-Depth >= 5` \u2014 too many self-calls in a chain | Refactor; you cannot self-recurse beyond depth 5. |\n| `timeout` | 408 | Action was still waiting when its 5 s budget ran out. Calls it had already started may still complete | Move heavy work out of the action or break into smaller calls; make the action safe to repeat. |\n| `body_too_large` | 413 | Request body > 1 MB | Trim the payload. |\n| `action_too_large` | 413 | Action source file > 256 KB | Split into multiple actions. |\n| `runtime_error` | 500 | Action threw at runtime | Read the message; common causes are unhandled rejections, missing `await`, or accessing undefined ctx fields. |\n\n### Anti-patterns\n\n- \u274C Putting a Stripe / OpenAI / Resend API key in frontend code. It must live in `ctx.secrets`.\n- \u274C `ctx.secrets.STRIPE_SECRET_KEY` (property access). The secrets API is `await ctx.secrets.get('server.stripe_secret_key')`; property access is silently `undefined`, and uppercase/unprefixed names cannot be stored at all.\n- \u274C `ctx.user.sub`. The verified user object is `{ id, email, pid }` \u2014 the JWT `sub` claim arrives as `ctx.user.id`.\n- \u274C Treating `ok: true` on an action response as \"the action succeeded\". The outer `ok` means \"it ran\"; your `{ error: '\u2026' }` sits inside `data`. A client that unwraps only once turns every action error into a phantom success.\n- \u274C An action that reads secrets or writes data without a caller gate as its first statement \u2014 `ctx.user`, or (only in a file marked `// machine-triggered: \u2026`) a header token / provider look-up. Actions are publicly invokable; your gate is the only authorization.\n- \u274C Promising a cron job, a nightly run or a background job. There is no scheduler; an action runs only when called.\n- \u274C Promising webhook signature verification, or accepting a webhook's body as true. Use the provider look-up below and work only with the fetched data.\n- \u274C A caller token in the URL (`?token=\u2026`). URLs end up in logs and dashboards; send it in a header.\n- \u274C Returning `{ error }` from a machine-triggered action for a TRANSIENT failure. A returned value is always HTTP 200 and acknowledges the delivery; **throw** so the sender gets a 500 and retries.\n- \u274C Calling `fetch('/api/p/.../actions/foo')` without first running `agentful-managed-actions upsert`. Will return 404.\n- \u274C Recursive actions calling themselves to \"spread work\". Will trip the depth guard at 5.\n- \u274C Using bare `fetch` instead of `ctx.fetch` from inside an action. The depth header won't propagate; you bypass the loop guard.\n- \u274C Naming actions `Hello.js`, `_internal.js`, or `actions/sub/foo.js`. The validator rejects (uppercase, leading underscore, subdirectory).\n- \u274C Hardcoding the API base URL into the action's `ctx.fetch` calls to other actions. Use a relative path or the project's own API origin.\n\n### `ctx.data` Is Privileged \u2014 It Bypasses `access_rule`\n\n`ctx.data` inside an action talks to the database **directly, with no\n`access_rule` enforcement**. It is a project-scoped, owner-level surface \u2014 the\nopposite of the `/data/*` end-user route:\n\n- It reads and writes **every** document in **every** collection, regardless of\n whether that collection is `owner`, `authenticated`, or `public`.\n- It does **not** check `created_by`. An action can read one user's `owner`\n docs and write into another user's.\n- Documents created via `ctx.data.create` are attributed `created_by: \"action\"`,\n never to an end-user. If you need owner attribution, store the owner's id in\n the document `data` yourself (e.g. `{ user_id: ctx.user.id, ... }`) and\n filter on it.\n- `ctx.data.list(collection, { limit })` returns a **plain array** of docs\n (`[{ doc_id, data, created_by, created_at }]`, NOT `{ documents: [...] }`),\n caps at 100, and does **no server-side filtering** \u2014 you filter in JS. For\n data sets that can exceed 100 rows, store an explicit owner/lookup field and\n design around the cap; do not assume `list` returns everything.\n\n- **End-user accounts are NOT readable through `ctx.data`.**\n `ctx.data.list('_users')` returns rows whose `data` is EMPTY: e-mail, display\n name and role are account fields and are not exposed to action code. Code\n that reads `u.data.email` gets `undefined` for every user and silently does\n nothing. The only address an action ever sees is `ctx.user.email` \u2014 the\n caller's own. When an action must later reach users who are NOT the caller\n (a digest, a notification from a machine-triggered action), the app keeps its\n own collection: a **user-authorized** action writes\n `{ user_id: ctx.user.id, email: ctx.user.email }` \u2014 taken from the verified\n JWT, never from `ctx.body`, or anyone could make the app mail strangers at\n the owner's cost \u2014 when the user signs in, and the machine-triggered action\n reads that collection. Never write to or delete from `_users` via `ctx.data`.\n\nThis is the intended mechanism for trusted/admin work. The trade-off: an action\nis only as safe as its own checks. **Always verify `ctx.user` before any\ncross-user read or write.**\n\n### RBAC & Secure Role Assignment (`owner` + Action)\n\nThe managed DB has **no role concept and no field-level validation**. On the\nend-user `/data/*` route the client controls the entire document body \u2014\nincluding any `role` field. Design around two facts:\n\n1. **Privilege escalation is possible by default.** If a `profiles` collection\n is `authenticated` or `owner`, a client can register and POST\n `{ role: \"admin\" }` for themselves. `owner` does NOT stop this \u2014 the user\n owns their own profile.\n2. **`owner` blocks admins too.** An `owner` collection correctly hides a\n client's data from other clients, but an admin also cannot read it over\n `/data/*`. Admin access must go through an action using `ctx.data`.\n\nSecure pattern \u2014 keep `role` server-owned and gate every change behind an\naction that verifies the **caller** is already an admin:\n\n```js\n// .server/actions/set-role.js \u2014 upsert BEFORE calling it from the client\nmodule.exports = async function (ctx) {\n if (!ctx.user) return { error: 'auth_required' };\n // 1. Verify the CALLER is an admin (ctx.data ignores access_rule, so this\n // works even though `profiles` is `owner`).\n const all = await ctx.data.list('profiles', { limit: 100 });\n const me = all.find(d => d.data.user_id === ctx.user.id);\n if (!me || me.data.role !== 'admin') return { error: 'forbidden' };\n // 2. Validate input, then apply to the target.\n const { target_user_id, role } = ctx.body || {};\n if (!['admin', 'member', 'client'].includes(role)) return { error: 'bad_role' };\n const target = all.find(d => d.data.user_id === target_user_id);\n if (!target) return { error: 'not_found' };\n await ctx.data.update('profiles', target.doc_id, { ...target.data, role });\n return { ok: true };\n};\n```\n\nRules for this pattern:\n\n- The client UI must **never** write the `role` field over `/data/*`. On\n self-registration, create the profile without `role` (or force a non-privileged\n default in the action) \u2014 never trust a client-sent role.\n- \"Owner OR admin\" **reads** (an admin viewing any client's invoices) use the\n same shape: keep the collection `owner`, expose admin access through an action\n that verifies `ctx.user` is an admin, then uses `ctx.data` to fetch across users.\n- **Bootstrapping the first admin:** see **First Admin \u2014 mode-correct\n protocol** below. Never invent a client-reachable route for it.\n- The 100-row `ctx.data.list` cap applies: if `profiles` can exceed 100 rows,\n this scan-in-JS lookup is unreliable. Until server-side filtering exists,\n store role lookups in a bounded collection or key admins by a known id set.\n\n## First Admin \u2014 mode-correct protocol\n\nWhen the app needs an admin (dashboard, moderation, `admin`-ruled collections),\nask the builder **\"How should the first admin account be created?\"** and offer\nONLY these options \u2014 they map to the platform's `_users.role` system\n(`user`/`admin`), which is what the `admin` access rule checks:\n\n1. **Fixed admin email (recommended).** Ask the builder for the address, then\n run:\n ```\n agentful-auth-config set $PROJECT_ID '{\"admin_email\":\"chef@firma.de\"}'\n ```\n Whoever registers (or later logs in) with exactly that address is promoted\n to `role: admin` **server-side, only after email verification** \u2014 no code\n needed in the app.\n2. **Manual via Data Manager.** The builder opens **Backend \u2192 Data Manager \u2192\n `_users` tab**, selects the registered user and sets the `role` dropdown to\n `admin` (the `verified` flag can also be set there if a mail never arrived).\n\n### `agentful-auth-config` CLI\n\n- `agentful-auth-config get $PROJECT_ID` \u2014 current `config.auth` state\n (`auth: null` = hardened pipeline not activated yet \u2192 registering works\n legacy-style without verification) plus `email_infrastructure`\n (`\"\"` = builder has not chosen one; verification mails will fail with\n `email_infra_unconfigured` until they pick one in project settings).\n- `agentful-auth-config set $PROJECT_ID '<json>'` \u2014 merge into `config.auth`.\n Keys: `methods` (subset of `email|google|oidc`; the server clamps against\n the org allowlist \u2014 verify the result in the response), `require_verified_login`\n (bool; default true once auth is configured), `admin_email`, `language`\n (`de`|`en`, auth-mail language).\n- Setting ANY key activates the hardened pipeline (verification mails +\n verified-login gate). Before activating it, run `get` and make sure\n `email_infrastructure` is not empty \u2014 otherwise tell the builder to choose\n Agentful Email or BYO in project settings first.\n- Email infrastructure is NOT settable via this CLI by design (audited\n builder decision, DE-data-region notice).\n\n**NEVER offer \"run SQL\" / \"insert into the database manually\" for\n`database.mode == managed` \u2014 there is no SQL surface; the managed DB is not a\nSQL database.** SQL-based instructions apply only to BYO-Supabase projects\n(different skill, different mode).\n\nPrefer the platform `_users.role` + `admin` access rule over inventing an\napp-level `profiles.role` system when the requirement is just \"one admin can\nsee/manage everything\" \u2014 the profiles-RBAC pattern above is for MULTI-role\napps (admin/member/client) that need roles beyond `user`/`admin`.\n\n---\n\n## Managed AI (platform-brokered LLM calls)\n\nManaged AI lets a generated app call an LLM through the platform:\n`/api/p/{PROJECT_ID}/ai/*`. The provider API key is stored **server-side by\nthe platform** (saved once by the end-user, or configured by the app owner)\nand is resolved only inside the platform broker \u2014 it never reaches the\nbrowser, the app bundle, actions, or the managed database.\n\n### Availability gate (fail-closed)\n\nManaged AI exists for this app **only** when the `[BACKEND STATUS]` preamble\nannounces `Managed AI: AVAILABLE` (equivalently: `agentful-backend-state`\nshows `managed_ai.available: true`). It also requires `database.mode ==\nmanaged` \u2014 the routes authenticate with the same end-user JWT as `/data/*`.\n\n**If it is NOT announced, the capability does not exist on this platform for\nthis app.** In that case:\n\n- Build the rest of the app; render AI features as clearly-labeled sample\n output (marked as sample), and tell the user plainly which platform\n capability is missing. Never wire a fake.\n- **NEVER improvise a substitute.** No collection that stores API keys\n (plain or \"encrypted\" \u2014 `api_key_enc` is the historical production\n failure), no cryptography in actions, no action that accepts an end-user's\n API key, no provider keys in `localStorage`/`sessionStorage`, no direct\n provider `fetch` from the browser with a user's key.\n- The one sanctioned alternative \u2014 only when `server.mode == managed` and\n the **owner** brings the key \u2014 is a Managed Action using\n `await ctx.secrets.get('server.openai_key')` + `ctx.fetch` to the\n provider (see Managed Actions above). That is app-funded, server-side AI;\n it must never accept or store end-user keys.\n\n### Two payment modes \u2014 decided at RUNTIME, not at build time\n\n`GET /ai/credentials` returns a `mode` field. Generated apps read it at\nruntime and adapt the UI; never hardcode the mode:\n\n- **`user_required`** (default): every logged-in end-user saves their own\n provider API key once; their key pays for their requests. UI: a small\n \"AI settings\" area with provider key form + presence/hint display.\n- **`project_shared`**: the app owner provides the provider access; end\n users never see a key form. UI: label the feature as provided by the app\n (e.g. \"AI features are provided by this app\") and handle the quota\n errors below. `PUT` answers `409 mode_project_shared` in this mode \u2014\n the app must not offer saving personal keys (existing personal keys stay\n listed and deletable).\n\nBoth modes require a **logged-in end-user** (`Authorization: Bearer` token\nfrom `auth/login`). Every `/ai/*` route answers `401 auth_required` without\nit \u2014 gate all AI UI behind login.\n\n### API surface\n\nBase: `/api/p/{PROJECT_ID}/ai/` \u2014 same envelope as the data API\n(`{ok:true, data:{\u2026}}` / `{ok:false, error:{code, message}}`).\n\n- `GET ai/credentials` \u2014 mode + stored-key metadata. **Never returns a key.**\n - `user_required`: `{mode:'user_required', credentials:[{provider, status,\n key_hint, created_at, updated_at, last_validated_at}]}`\n - `project_shared`: `{mode:'project_shared', provider, models:[\u2026],\n project_key_present, credentials:[\u2026]}`\n - `?include=providers` (user_required only): adds `providers:[{id, label,\n models:[\u2026]}]` \u2014 the live server-side allowlists. **Render provider and\n model pickers from this registry, never from a hardcoded list**: the\n platform adds models over time and a baked-in list ages with the app.\n Fetch it once per session (it is a few hundred model ids), cache it in\n state, and fall back to one cheap documented default per provider (table\n below) if the field is absent. In `project_shared` the response's pinned\n `provider`/`models` ARE the offer \u2014 no registry there.\n- `PUT ai/credentials/{provider}` \u2014 body `{api_key: '\u2026'}`. The platform\n validates the key against the real provider BEFORE storing; an invalid key\n is rejected (`credential_invalid`) and nothing is stored. Success returns\n metadata only: `{provider, status:'active', key_hint, updated_at}`.\n- `DELETE ai/credentials/{provider}` \u2014 `{provider, deleted:true}`.\n Idempotent; works in every mode.\n- `POST ai/generate` \u2014 body `{provider, model, prompt, system?, max_tokens?}`\n \u2192 `{text, provider, model, credential_mode, usage:{input_tokens,\n output_tokens}}`. Single-turn, non-streaming. In `project_shared`, omit\n `provider` (or send exactly the pinned one \u2014 any other id is refused, not\n redirected).\n\nBounded limits (enforced server-side): `prompt` \u2264 24000 chars, `system` \u2264\n4000 chars, `max_tokens` clamped to \u2264 4096 (default 1024), per-user request\nbudget (default 20 generate/min, 5 credential saves/min, 2 concurrent\ngenerations), provider timeout ~25 s. For chat features, keep conversation\nhistory client-side and include only the recent turns in `prompt`, trimming\nto stay under the cap.\n\n### Providers\n\n| provider id | example allowlisted models |\n|---|---|\n| `openai` | `gpt-4o-mini`, `gpt-4o`, `gpt-4.1-mini` |\n| `anthropic` | `claude-3-5-haiku-latest`, `claude-sonnet-4-0` |\n| `google` | `gemini-2.0-flash`, `gemini-2.5-flash` |\n| `deepseek` | `deepseek-chat`, `deepseek-reasoner` |\n| `zai` | `glm-5.3-flash`, `glm-5.3` |\n\nAdditional OpenAI-compatible providers are registered (`openrouter`,\n`fireworks-ai`, `moonshotai`, `nebius`, `baseten`, `siliconflow`). Models are\nallowlisted server-side per provider: an unknown provider or model returns\n`provider_not_allowed` / `model_not_allowed` **with the allowed list in the\nerror message** \u2014 surface that message. The table above lists stable\nDEFAULTS, not the full allowlists: the full, current lists come from\n`GET ai/credentials?include=providers` (see above) \u2014 build pickers from\nthat registry so the app never ages, and validate a stored model against it\n(fall back to the provider default when a stored id is no longer listed).\nPick ONE provider with a cheap default model per feature (e.g.\n`openai` + `gpt-4o-mini`) unless the user asks for a choice.\n\n### Client pattern\n\nAppend to the shared client (`src/lib/data.js` \u2014 reuses `jsonOrThrow` and\n`authHeader` from above):\n\n```js\nexport const ai = {\n // Runtime mode signal \u2014 call after login, before rendering AI UI.\n async status() {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials`, {\n headers: { ...authHeader() },\n })); // -> {mode, credentials, provider?, models?, project_key_present?}\n },\n async saveKey(provider, apiKey) {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials/${provider}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ api_key: apiKey }),\n })); // -> {provider, status:'active', key_hint, updated_at} \u2014 never the key\n },\n async deleteKey(provider) {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials/${provider}`, {\n method: 'DELETE', headers: { ...authHeader() },\n })); // -> {provider, deleted:true}\n },\n async generate({ provider, model, prompt, system, max_tokens }) {\n return jsonOrThrow(await fetch(`${BASE}/ai/generate`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ provider, model, prompt, system, max_tokens }),\n })); // -> {text, provider, model, credential_mode, usage}\n },\n};\n```\n\nMode-adaptive rendering, in every app with an AI feature:\n\n```js\nconst aiState = await ai.status();\nif (aiState.mode === 'project_shared') {\n // No key form. Label: \"AI features are provided by this app.\"\n // Generate with the pinned provider/models from aiState.\n} else {\n // user_required: render key settings (presence/hint) + generate UI\n // only when a credential for the chosen provider exists.\n}\n```\n\n### Settings UI rules (user_required)\n\n1. The key input is `type=\"password\"`. After a successful `saveKey`, **clear\n the input** and render the returned `key_hint` (last 4 chars) \u2014 the app\n never displays, stores, or echoes the full key anywhere.\n2. The key exists in exactly one place in the app: the in-flight `PUT` body.\n Never in `localStorage`/`sessionStorage`, never in app state beyond the\n form field, never in a collection, never in an action payload.\n3. Presence is what `GET ai/credentials` returns \u2014 render provider,\n `key_hint`, `updated_at`, and a delete button per stored credential.\n4. On `credential_invalid` during save: tell the user WHICH provider\n rejected the key and that nothing was stored (\"does the key match the\n selected provider?\"); do not retry automatically. Keep this message\n distinct from the runtime one (stored key expired/revoked \u2192 save a new\n key) \u2014 one generic text for both hides provider mismatches.\n5. The key save PUTs to the provider **currently selected in the form**,\n and persists that provider/model choice in the same action \u2014 never to a\n previously stored provider default. A key sent to yesterday's provider\n fails validation against the wrong API and reads like a broken key\n (real incident: FeedSum, 2026-09-01).\n6. Point users to their provider dashboard to create a key; the app never\n asks for anything but the key string.\n\n### Error code \u2192 user-facing handling\n\n| `error.code` | HTTP | Meaning | What the app does |\n|---|---|---|---|\n| `auth_required` | 401 | Not logged in | Show login; AI UI is login-gated. |\n| `feature_unavailable` | 503 | Platform AI is switched off | \"AI features are temporarily unavailable.\" Platform state \u2014 do not retry in a loop, nothing to fix in code. |\n| `no_credential` | 404 | user_required: no key stored for this provider | Open the AI settings / key form. |\n| `credential_invalid` | 400 | Provider rejected the key (save or generate) | user_required: ask for a new key. project_shared: \"Contact the app owner.\" |\n| `invalid_key_format` | 400 | Key string malformed (8\u2013512 chars, no whitespace) | Inline form validation message. |\n| `mode_project_shared` | 409 | PUT while the app provides AI | Hide/disable the key form (the mode signal was missed \u2014 re-check `status()`). |\n| `project_key_missing` | 503 | project_shared: owner has not configured the key yet | \"The app owner has not configured AI access yet.\" |\n| `project_quota_exhausted` | 429 | project_shared: app's daily AI budget used up | \"The app's daily AI budget is used up \u2014 resets at midnight UTC.\" |\n| `provider_not_allowed` / `model_not_allowed` | 400 | Provider/model not in the server allowlist | Surface the message (it names what IS allowed); fix the generated default. |\n| `missing_prompt` / `prompt_too_large` | 400/413 | Empty prompt / over the 24k-char cap | Trim client-side before sending. |\n| `rate_limited` / `concurrency_exceeded` | 429 | Per-user budget hit | \"Please wait a moment and try again.\" No auto-retry loops. |\n| `provider_rate_limited` | 429 | The provider rate-limited the key | Same message; the key's own quota is exhausted. |\n| `provider_timeout` | 504 | Provider took > ~25 s | \"The AI provider did not answer in time \u2014 try again.\" |\n| `provider_unreachable` / `provider_rejected` / `provider_error` | 502 | Provider-side failure | \"The AI provider had a problem \u2014 try again later.\" |\n| `credential_unreadable` | 500 | Stored credential cannot be decrypted | user_required: delete the key and save it again. project_shared: contact the app owner. |\n| `ai_config_invalid` | \u2014 | App's AI configuration is broken | \"Contact the app owner.\" |\n\n### project_shared \u2014 owner setup (tell the BUILDER, do not generate)\n\n`project_shared` is configured by the app **owner**, not by generated code:\nthe backend declaration (`.agentful/backend.json`, applied via `agentful\npush`) carries `\"ai\": {\"credential_mode\": \"project_shared\", \"provider\":\n\"openai\", \"daily_request_limit\": 500}`, and the owner stores the paying key\nin **Backend \u2192 Secrets** as `server.ai_<provider>_key` (e.g.\n`server.ai_openai_key`). Until the key is stored, `generate` answers\n`project_key_missing` \u2014 the declaration alone activates nothing. There is no\nengine CLI for this: when the user wants \"the app provides AI for all\nusers\", explain those two owner steps and generate the mode-adaptive UI \u2014\nwhich is correct in both modes anyway.\n\n### Managed AI vs. owner-key actions \u2014 pick the right lane\n\n- **End-user-facing text generation** (chat box, \"summarize this\",\n writing aids) \u2192 Managed AI routes. In `user_required` each user's key\n pays; in `project_shared` the owner pays with a mandatory daily quota.\n- **Server-side AI inside app logic** (a digest the user triggers, enrichment\n before storing a document) with the OWNER's key \u2192 Managed Action with\n `ctx.secrets` + `ctx.fetch` (works independently of Managed AI\n availability).\n- **Never**: an action that receives an end-user's API key in its body, an\n action that proxies \"bring your own key\" traffic, or any app-built key\n storage. End-user keys are exclusively a platform concern.\n\n### Anti-patterns (Managed AI)\n\n- \u274C A `settings`/`api_keys` collection with an `api_key` or `api_key_enc`\n field \u2014 this is the FeedSum production incident. End-user keys never\n touch the managed database, in any encoding.\n- \u274C Hand-rolled cryptography anywhere (actions have no crypto primitive\n by design; the platform broker owns key handling).\n- \u274C Saving the key in `localStorage`/`sessionStorage` or app state \"so the\n user doesn't have to re-enter it\" \u2014 presence comes from `GET\n ai/credentials`, the key itself is never needed again client-side.\n- \u274C Calling `api.openai.com` (or any provider) directly from the browser\n with a user's key.\n- \u274C Rendering a key form without checking `mode` first, or hardcoding\n `project_shared` texts into a `user_required` app.\n- \u274C Retrying `credential_invalid`, `feature_unavailable`, or 429s in a\n loop.\n- \u274C Offering every provider/model in a giant dropdown. One provider, one\n default model, unless the user asks.\n",
7690
+ "agentful-managed-db": "---\nname: agentful-managed-db\ndescription: Managed Database protocol for `database.mode == managed`. Covers schema upsert, CRUD against `/api/p/{PROJECT_ID}/data/*` and `/api/p/{PROJECT_ID}/auth/*`, error-code remediation, and per-framework client patterns (Vue, React, Svelte, SvelteKit, Astro, Vanilla). Load when the backend preamble shows `Database: managed`.\n---\n\n## When To Use\n\nLoad this skill **only** when `agentful-backend-state` reports `database.mode: \"managed\"` with `status: \"active\"`. Do not load it for `byo` (Supabase / custom server) or when the database is not configured.\n\n## Hard Rules\n\n1. **Collections do not auto-create.** Writes to a non-existent collection return 404 with `error.code: not_found`. For every collection your code reads or writes, if it is not listed in the backend preamble's `Managed collections` block, you MUST run `agentful-managed-collections upsert <project_id> '<json>'` BEFORE writing the code that touches it.\n2. **Never wrap data-API calls in a swallow-all `try/catch`.** Swallowing masks 4xx errors and produces apps that look-fine-but-write-nothing.\n3. **Never set a `seeded` flag unless every write returned 201.** Partial-success seeds drift state silently.\n4. **Do not call this a \"server\".** It is a managed database behind a gateway. Use \"the database\" when talking to the user.\n5. **Do not target `/data/_collections`** from generated code. That's the owner-only schema endpoint; use the `agentful-managed-collections` CLI for schema work.\n6. **On 5xx, do not speculate.** Surface `error.correlation_id` to the user verbatim and stop. Do not invent internal causes (DynamoDB, operators, system collections, etc.).\n\n## Authoring Protocol \u2014 for every collection touch\n\nRun, in order:\n\n1. **Read the preamble.** The `[BACKEND STATUS]` block lists existing `Managed collections` with their fields and access rules. If your target collection is there with the right shape, skip to step 3.\n2. **Upsert if missing or schema mismatch:**\n ```\n agentful-managed-collections upsert <project_id> '{\"name\":\"todos\",\"access_rule\":\"owner\",\"schema\":{\"fields\":[{\"name\":\"title\",\"type\":\"string\",\"required\":true},{\"name\":\"done\",\"type\":\"boolean\"}]}}'\n ```\n Field types: `string`, `text`, `number`, `boolean`, `select` (with `options`), `date` (ISO 8601 calendar date `YYYY-MM-DD`), `datetime` (ISO 8601 string), `json` (object or array). Any other type is rejected by the platform (400 with the allowed list).\n Access rules: `public` (anyone), `authenticated` (any logged-in end-user), `owner` (only `created_by` user), `admin` (only end-users whose `_users.role` is `admin`).\n Every successful upsert is also DECLARED: the command mirrors the collection (as the platform stored it) into `.agentful/backend.json` in the workspace and answers `\"declaration\": \".agentful/backend.json\"`. That file is the project's backend declaration \u2014 it travels with the code (a local `agentful push` applies it again, idempotently) and the Backend tab shows it. Do not edit it to claim a collection exists; the upsert output is the only confirmation. Never commit it to `.gitignore`.\n3. **Write the client code** using the patterns below. Use the EXACT field names from the schema. Do not invent fields.\n4. **Test the happy path** by inspecting the response. Real 201 / 200, not a swallowed error.\n\n## Choosing An Access Rule\n\n`access_rule` is set per collection at upsert time and enforced on every end-user\n(`/data/*`) request. There are exactly four rules \u2014 pick by use case:\n\n| Use case | Rule | Why |\n|---|---|---|\n| Content anyone may read/write without login (public poll, guestbook) | `public` | No JWT required. |\n| Public content the app seeds once and the UI only reads | `public` | Seed at build time; clients read only. |\n| Shared data **every** logged-in user may read AND edit (team wiki, shared catalog) | `authenticated` | Any valid end-user JWT passes. **No per-row owner check.** |\n| Per-user private data (todos, drafts, a user's own orders) | `owner` | Only the `created_by` end-user can read/update/delete each doc. |\n| Data only the app's admins may read/write (moderation queues, settings) | `admin` | Only end-users whose `_users.role` is `admin` (owner-set, see First Admin below). |\n| Per-user data an **admin must also access** (invoices, tickets, client records) | `owner` + admin via Action | `owner` protects the client; admin reads/writes through a Managed Action. See RBAC below. |\n| A field only the server may set (`role`, `plan`, `verified`, `balance`) | `owner`, with that field written **only** via an Action | No field-level rules exist \u2014 gate the whole mutation behind an Action. |\n\n**Two traps to design around:**\n\n1. **`authenticated` is NOT per-user isolation.** It means *every* logged-in\n user can read and write *all* documents in the collection. For \"each user\n sees only their own\", use `owner`.\n2. **There is no combined `owner_or_admin` rule and no role concept in the data\n layer.** A multi-role portal (admin / member / client) cannot be expressed by\n `access_rule` alone. The supported pattern is `owner` + a Managed Action that\n verifies the caller \u2014 see **RBAC & Secure Role Assignment** under Managed\n Actions. (Requires `server.mode == managed`.)\n\n## API Surface\n\nBase: `/api/p/{PROJECT_ID}/`\n\n**Auth (end-user):**\n- `POST auth/register` \u2014 `{email, password, display_name?}`. Two response shapes:\n - Verification pipeline ACTIVE (project has `config.auth`, default): `201 {verification_required:true, user:{...}}` \u2014 **NO token yet**; the user must confirm their email first (mail is sent automatically).\n - Legacy project (no `config.auth`) or `require_verified_login:false`: `201 {token, verification_required:false, user:{...}}`.\n- `POST auth/login` \u2014 `{email, password}` \u2192 `{token, user:{...}}`. Blocks with `403 email_unverified` when the project requires verified logins and the account is not verified yet \u2192 show a \"check your inbox\" state with a resend button.\n- `GET auth/me` \u2014 Bearer token \u2192 `{user:{...}}`\n- `POST auth/verify` \u2014 `{uid, token}` (from the mail link) \u2192 `{token, user}` (auto-login after verification).\n- `POST auth/resend-verification` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/request-password-reset` \u2014 `{email}` \u2192 always `200 {sent:true}` (no user enumeration).\n- `POST auth/reset-password` \u2014 `{uid, token, password}` \u2192 `200 {reset:true}`. Also marks the mailbox verified.\n\n`user` shape: `{id, email, display_name, role, verified, provider, created_at}`.\n\n**Auth mail links:** verification/reset mails link to the deployed app as\n`{app_url}/?ta_action=verify&uid=\u2026&token=\u2026` and `{app_url}/?ta_action=reset&uid=\u2026&token=\u2026`.\n**Every generated app with auth MUST handle these two query params on load**\n(see Client Patterns).\n\n**Login methods governance:** offer ONLY the login methods the project's\n`config.auth.methods` allows (check with `agentful-auth-config get`;\ndefault `[\"email\"]`). Do NOT generate \"Sign in with Google\"/SSO buttons unless\n`google`/`oidc` is listed \u2014 the platform refuses unlisted methods server-side.\n\n**Google login (when `google` IS listed):** a \"Continue with Google\" button\ncalls `googleAuth.start()` (see Client Patterns) \u2192 central broker\n`api.agentful.dev/auth/oauth/google/start` \u2192 Google \u2192 back to the app with the\nJWT in the URL fragment; call `handleGoogleReturn()` at startup to complete\nthe login. Google users arrive `verified: true` (Google verified the mailbox),\nexisting email accounts with the same address are linked automatically, and\nthe `admin_email` bootstrap applies. Show `auth_error` codes as a friendly\nmessage (`auth_method_not_allowed` \u2192 \"Google login is not available for this\napp\"); never retry in a loop.\n\n**Data:**\n- `GET data/{collection}` \u2014 list (paginated; `?limit=`, `?cursor=`)\n- `GET data/{collection}/{docId}` \u2014 single doc\n- `POST data/{collection}` \u2014 body `{data: {...}}` \u2192 `{ok:true, data:{doc_id, collection, data, created_at}}`\n- `PUT data/{collection}/{docId}` \u2014 body `{data: {...}}` \u2192 updated doc\n- `DELETE data/{collection}/{docId}` \u2192 `{ok:true, data:{deleted, collection}}`\n\nEnd-user routes (above) require `Authorization: Bearer <token>` from `auth/register` or `auth/login`. The collection's `access_rule` enforces what each token may read/write.\n\n## Response Shapes\n\n**Success:** `{ \"ok\": true, \"data\": {...} }` \u2014 `data` for lists has `{documents:[...], count, cursor}`.\n\n**Error:** `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"correlation_id\"?: \"...\" } }`\n\nStatus codes are HTTP-conventional (201 on create, 200 on read/update/delete, 4xx for client errors, 5xx for platform).\n\n**Actions are the one exception:** `POST \u2026/actions/{name}` returns `{ \"ok\": true, \"data\": <whatever your action returned> }` with HTTP 200 whenever the action *ran to completion*. The outer `ok` only says \"the platform executed it\" \u2014 your own `{ error: '\u2026' }` return lands verbatim inside `data`, it does NOT flip the outer `ok` and cannot set the HTTP status (only a thrown error becomes `ok:false` / `runtime_error` 500). The client must therefore check `body.data.error` after the envelope check (see `callAction` below).\n\n## Error Code \u2192 Remediation\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `not_found` | 404 | Collection doesn't exist, OR doc id doesn't exist | If collection: run `agentful-managed-collections upsert` then retry. If doc: surface to user. |\n| `auth_required` | 401 | `/data/*` on a non-`public` collection without a `Authorization: Bearer` token | Send the end-user to login; do not retry without a token. |\n| `invalid_token` | 401 | The Bearer token on `/data/*` is invalid or expired (`wrong_project` 403 = token from another project) | Clear the stored token and show the login state. |\n| `email_unverified` | 403 | Login blocked until the user confirms their email | Show \"confirm your email\" state + resend button (`auth/resend-verification`). |\n| `invalid_token` | 400 | Verification/reset link invalid, expired, or already used | Offer resend (`resend-verification`) or a new reset request. |\n| `too_many_attempts` | 429 | Auth rate limit hit (failed logins / mail requests) | Tell the user to wait a few minutes; do not auto-retry. |\n| `email_infra_unconfigured` | 409 | Project has no email infrastructure selected | Tell the BUILDER (not the end-user): choose \"Agentful Email\" or BYO in project settings \u2192 Email infrastructure. |\n| `email_delivery_unavailable` | 409 | The app has no published URL yet \u2014 verification/reset mails need a deployed app to link to (the preview URL does not count) | Platform state, not a code bug: tell the BUILDER to publish the app; show the end-user \"sign-up by email is not available yet\". Google login works on the preview already. Never treat as transient; never retry in a loop. |\n| `email_send_failed` | 502 | Mail transport failed transiently | Tell the user to try again later. |\n| `schema_validation_failed` | 400 | Payload doesn't match the collection's schema | Re-read the schema in the preamble; fix field names/types/required-ness; do not retry blindly. |\n| `readonly_collection` | 403 | Collection or doc is system-protected (e.g. `_users` via end-user route) | Use the correct route (e.g. `auth/register` for `_users`); do not retry. |\n| `forbidden` | 403 | Access rule denied this end-user | Tell the user they need to log in / lack permission. |\n| `already_exists` | 409 | `doc_id` collision or conditional check failed | Let `doc_id` auto-generate (omit it). |\n| `too_large` | 413 | Document > 256 KB | Split or trim payload. |\n| `quota_exceeded` | 429 | DDB throttling / request-limit spillover (transient) | The doctor returns `action: retry_with_backoff`. Sleep + retry up to 3 times per `details.backoff_ms`. **Surface `correlation_id` to the user ONLY after all attempts exhaust.** |\n| `transient_storage_error` | 503 | DDB internal / service-unavailable (transient) | Same as `quota_exceeded`: doctor returns `retry_with_backoff`; engine handles silently until budget exhausts. |\n| `internal_storage_error` / `write_failed` / `delete_failed` | 500 | Platform-side error, NOT classified retryable | Delegate to `@agentful-managed-db-doctor` with `correlation_id`. Surface its `user_message` verbatim. Do NOT retry in a loop. Do NOT invent a root cause. |\n\n## Client Patterns\n\nA tiny client used everywhere. Define once per project; reuse for all collections.\n\n### Vanilla / shared base\n\n```js\n// src/lib/data.js\nconst BASE = `/api/p/${PROJECT_ID}`; // set PROJECT_ID at build time\nconst tokenKey = 'mm_auth_token';\n\nfunction authHeader() {\n const t = localStorage.getItem(tokenKey);\n return t ? { Authorization: `Bearer ${t}` } : {};\n}\n\nasync function jsonOrThrow(res) {\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body.ok) {\n const err = new Error(body.error?.message || `HTTP ${res.status}`);\n err.code = body.error?.code;\n err.correlation_id = body.error?.correlation_id;\n err.status = res.status;\n throw err;\n }\n return body.data;\n}\n\nexport const auth = {\n async register(email, password, display_name) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/register`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password, display_name }),\n }));\n // verification_required \u2192 NO token yet; caller must show \"check your inbox\".\n if (data.token) localStorage.setItem(tokenKey, data.token);\n return data; // {verification_required, user, token?}\n },\n async login(email, password) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/login`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email, password }),\n }));\n localStorage.setItem(tokenKey, data.token);\n return data.user;\n },\n async verify(uid, token) {\n const data = await jsonOrThrow(await fetch(`${BASE}/auth/verify`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token }),\n }));\n localStorage.setItem(tokenKey, data.token); // auto-login after verify\n return data.user;\n },\n async resendVerification(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/resend-verification`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async requestPasswordReset(email) {\n return jsonOrThrow(await fetch(`${BASE}/auth/request-password-reset`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ email }),\n }));\n },\n async resetPassword(uid, token, password) {\n return jsonOrThrow(await fetch(`${BASE}/auth/reset-password`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ uid, token, password }),\n }));\n },\n logout() { localStorage.removeItem(tokenKey); },\n token() { return localStorage.getItem(tokenKey); },\n};\n\n// Google login (ONLY when 'google' \u2208 config.auth.methods \u2014 never generate\n// this button otherwise; the platform refuses unlisted methods server-side).\n// Redirects to the central Agentful OAuth broker; after Google consent the\n// broker 302s back to `redirect` with the app JWT in the URL FRAGMENT:\n// https://<your-app>/#token=<jwt>&provider=google (or #auth_error=<code>)\nexport const googleAuth = {\n start(redirect = location.origin + '/') {\n const url = new URL('https://api.agentful.dev/auth/oauth/google/start');\n url.searchParams.set('project_id', PROJECT_ID);\n url.searchParams.set('redirect', redirect); // must be THIS app's https origin\n location.href = url.toString();\n },\n};\n\n// REQUIRED whenever the Google button is generated: pick up the broker return\n// on app load (fragment token \u2192 login; auth_error \u2192 user-visible message).\nexport function handleGoogleReturn() {\n const h = new URLSearchParams(location.hash.slice(1));\n const token = h.get('token'), err = h.get('auth_error');\n if (!token && !err) return null;\n history.replaceState(null, '', location.pathname + location.search); // strip token from URL\n if (err) return { ok: false, error: err }; // e.g. auth_method_not_allowed, oauth_failed\n localStorage.setItem(tokenKey, token);\n return { ok: true, provider: h.get('provider') || 'google' };\n}\n\n// REQUIRED in every app with auth: handle the mail links on app load.\n// Call once at startup (before router init is fine).\nexport async function handleAuthMailAction() {\n const p = new URLSearchParams(location.search);\n const action = p.get('ta_action'), uid = p.get('uid'), token = p.get('token');\n if (!action || !uid || !token) return null;\n history.replaceState(null, '', location.pathname); // strip token from URL\n if (action === 'verify') {\n try { const user = await auth.verify(uid, token); return { action, ok: true, user }; }\n catch (e) { return { action, ok: false, error: e.code || 'invalid_token' }; }\n }\n if (action === 'reset') return { action, ok: true, uid, token }; // show new-password form, then auth.resetPassword(uid, token, pw)\n return null;\n}\n\nexport const data = {\n async list(collection, opts = {}) {\n const qs = new URLSearchParams(opts).toString();\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}${qs ? '?' + qs : ''}`, {\n headers: { ...authHeader() },\n }));\n },\n async get(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n headers: { ...authHeader() },\n }));\n },\n async create(collection, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async update(collection, docId, data) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ data }),\n }));\n },\n async remove(collection, docId) {\n return jsonOrThrow(await fetch(`${BASE}/data/${collection}/${docId}`, {\n method: 'DELETE',\n headers: { ...authHeader() },\n }));\n },\n};\n```\n\n### React / Vue / Svelte\n\nUse the same `data.js` / `data.ts` module above; wrap in framework-native state primitives.\n\n- **React:** call from `useEffect` for reads, `useState` for results; surface `err.code`/`err.correlation_id` to the user when caught. Do not put data calls in render bodies.\n- **Vue:** use `onMounted` for reads and a `ref()` for results; same error surfacing.\n- **Svelte / SvelteKit:** call from `onMount` (or a `load` function in SvelteKit); SvelteKit static-adapter projects must NOT use server `load` (no Node runtime in static deploy).\n- **Astro:** call only from client-side islands; no server fetch (static-only build).\n\n### TypeScript types\n\nGenerate the per-collection type from the preamble's field list:\n\n```ts\n// Example for a collection with fields: title:string*, done:boolean\ntype Todo = { title: string; done?: boolean };\n\n// And response wrappers:\ntype DataDoc<T> = { doc_id: string; collection: string; data: T; created_at: string };\ntype DataList<T> = { documents: DataDoc<T>[]; count: number; cursor?: string };\n```\n\n## Anti-patterns\n\n- \u274C `try { await data.create(...) } catch { /* ignore */ }` \u2014 masks failures.\n- \u274C Hardcoding `doc_id` for \"convenience\" \u2014 causes `already_exists` 409 on retry.\n- \u274C Writing to a collection name that doesn't appear in the preamble \u2014 `not_found` 404.\n- \u274C Using the schema endpoint `/data/_collections` from client code \u2014 owner-only, end-users get 403.\n- \u274C Storing the JWT anywhere other than `localStorage` under a project-scoped key; do not put it in cookies (CORS) or in `sessionStorage` (lost on tab close).\n- \u274C Telling the user \"the database is down\" because of a 5xx. Surface the `correlation_id` and stop.\n- \u274C Surfacing `correlation_id` on transient errors (`quota_exceeded`, `transient_storage_error`) before the doctor's `retry_with_backoff` budget is exhausted. The whole point is the user sees nothing while the retry loop is in play; only escalate after all `max_attempts` fail.\n\n## Diagnostic delegation\n\nIf you hit a 5xx that doesn't map to a retry-able 4xx in the table above, do not diagnose yourself. Delegate to `@agentful-managed-db-doctor` (added in PR 1.3) with the `correlation_id` from the response. The doctor has constrained tools and cannot fabricate platform internals.\n\n---\n\n## Managed Actions (only when `server.mode == managed`)\n\nManaged Actions are small Node.js functions the platform runs for the project at `/api/p/{PROJECT_ID}/actions/{name}`. Use them when a frontend operation needs (a) a secret API key, (b) a non-public/secured external API, or (c) server-enforced trust (price computation, admin actions). For pure CRUD against the managed DB, use the data API directly; do NOT route everything through an action.\n\n**What an action is NOT.** It is a request/response function: JSON in, JSON out, and it only runs when something calls it. There is **no scheduler** (no cron, no background jobs, no queues), no sockets or streaming, and no response of your own shape: whatever an action returns goes out as HTTP 200 `{ ok: true, data }`. Do not promise any of these; \"Two classes of actions\" below has the honest ways out. What an action CAN do since 2026-09: check that a webhook really comes from its sender \u2014 the platform host verifies the signature (`ctx.webhooks.verifyStripe`, `ctx.webhooks.verifyGitHub`, `ctx.verifyHmac`), and `ctx.rawBody` carries the body exactly as received, form-encoded ones included. See \"Incoming webhooks\".\n\n### Hard rules (Managed Actions)\n\n1. **Upsert FIRST, fetch SECOND.** If your generated code calls `fetch('/api/p/.../actions/{name}')`, you MUST upsert the action via the CLI BEFORE writing the fetch call. An undeployed action returns `404 action_not_found`; the preamble's `Managed actions` list is the ground truth for what exists.\n2. **The sandbox has NO module system.** No `require` of any kind (incl. `module.require`), no `import` / dynamic `import()`, no npm packages, no Node.js core modules (not even `crypto`), no `process` and no environment variables. \"Node.js 20\" is the host the platform runs actions on, NOT your action's API. An action is a plain script whose full surface is `ctx.fetch`, `ctx.data`, `ctx.secrets`, `ctx.user`, `ctx.body`, `ctx.rawBody`, `ctx.headers`, `ctx.query`, `ctx.method`, `ctx.webhooks`, `ctx.verifyHmac` plus these globals: `JSON`, `Math`, `Date`, `URL`/`URLSearchParams`, `Buffer`, `TextEncoder`/`TextDecoder`, `atob`/`btoa`, `AbortController`/`AbortSignal`, `Promise`, `RegExp`, standard primitives and `console`. There is deliberately no crypto primitive in sandbox code \u2014 key handling and cryptography live in the trusted platform host (`ctx.secrets`, and for webhook signatures `ctx.webhooks` / `ctx.verifyHmac`), never in generated action code; do not probe for one and do not hand-build an HMAC or a hash. The validator rejects module-system usage at upload time with `{code: 'action_validation_failed', reason: 'require_unavailable' | 'dynamic_import_unavailable' | 'esm_not_supported' | 'process_unavailable'}`.\n3. **Secrets are read with `await ctx.secrets.get('<name>')` \u2014 never property access.** `ctx.secrets` has exactly one method, `get(key)`. `ctx.secrets.SOME_KEY` is silently `undefined`. Secret names must match `^(database|server)\\.[a-z][a-z0-9_]{0,126}$` \u2014 use `server.stripe_secret_key`, not `STRIPE_SECRET_KEY` (uppercase, unprefixed names cannot even be stored in Backend \u2192 Secrets).\n4. **Actions are publicly invokable \u2014 every action starts with a caller gate.** Nothing else gates who can call it. `ctx.user` is the JWT-verified end-user (`{ id, email, pid }`) or `null`. Any action that touches a secret, writes data, or returns non-public information must begin with exactly one of two gates: **user-authorized** (the default) \u2014 `if (!ctx.user) return { error: 'auth_required' }` before anything else; or **machine-triggered** \u2014 only for callers that cannot carry an app JWT (an external scheduler, a webhook sender), marked with `// machine-triggered: \u2026` as the FIRST line of the file, gated by the platform's signature check (`ctx.webhooks.\u2026` / `ctx.verifyHmac`) where the sender signs, by a token from a request **header** compared against `ctx.secrets`, or by an authoritative look-up at the provider. An unmarked action without a `ctx.user` check is a bug. See \"Two classes of actions\".\n5. **Outbound calls from inside an action must use `ctx.fetch`**, not a bare `fetch`. `ctx.fetch` injects `X-Action-Depth` on self-action URLs so the platform's loop detector can break runaway recursion. External URLs are passed through unchanged.\n6. **Cost shape.** 5 s budget per action (10 s hard limit), 256 MB RAM, 1 MB request body, 256 KB action source, per-project concurrent invocations capped at 10. The 5 s are a planning figure: an action still waiting on `ctx.fetch`/`ctx.data` when the budget runs out is answered with `408 timeout`, but calls it already started may still complete \u2014 a 408 does not mean \"nothing happened\". Plan for `429 concurrency_exceeded` under load and surface it to the user gracefully (e.g. retry with backoff or \"try again in a moment\").\n\n### Action shape\n\n```js\n// .server/actions/checkout.js\nmodule.exports = async function (ctx) {\n // ctx.body \u2014 parsed JSON request body ({} when the body is not JSON)\n // ctx.rawBody \u2014 the body exactly as received, as text (webhook signatures, form-encoded bodies)\n // ctx.headers \u2014 request headers, names lower-cased\n // ctx.user \u2014 JWT-verified end-user { id, email, pid }, or null if not logged in\n // ctx.data \u2014 same CRUD surface as the public data API, scoped to this project\n // ctx.secrets \u2014 async accessor for Backend \u2192 Secrets: await ctx.secrets.get('server.stripe_secret_key')\n // ctx.fetch \u2014 depth-aware fetch wrapper\n if (!ctx.user) return { error: 'auth_required' };\n const apiKey = await ctx.secrets.get('server.stripe_secret_key');\n if (!apiKey) return { error: 'missing_server_secret' };\n const stripeRes = await ctx.fetch('https://api.stripe.com/v1/checkout/sessions', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({ /* \u2026 */ }).toString(),\n });\n const session = await stripeRes.json();\n return { url: session.url };\n};\n```\n\n**What the caller receives.** The platform wraps the return value verbatim:\n`{ ok: true, data: { url } }` on success and \u2014 for the two early returns\nabove \u2014 `{ ok: true, data: { error: 'auth_required' } }`, still HTTP 200.\nKeep the `{ error: '<code>' }` convention for application-level failures\n(do not nest a second `{ok:false, error:{\u2026}}` envelope) and unwrap twice\nin the frontend:\n\n```js\n// src/lib/actions.js \u2014 one helper for every action call\nasync function callAction(name, payload) {\n const res = await fetch(`${BASE}/actions/${name}`, {\n method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify(payload ?? {}),\n });\n const result = await jsonOrThrow(res); // platform envelope: action_not_found, runtime_error, \u2026\n if (result && typeof result === 'object' && result.error) {\n const err = new Error(result.error); // the action's own { error: '<code>' }\n err.code = result.error;\n throw err;\n }\n return result;\n}\n```\n\n### Two classes of actions\n\nHard rule 4 knows two kinds of caller. Pick one per action and never mix them.\n\n| | User-authorized (default) | Machine-triggered |\n|---|---|---|\n| Caller | The app in the browser, end-user logged in | An external scheduler, a webhook sender, a receiver you put in front |\n| Mandatory gate (first statement) | `if (!ctx.user) return { error: 'auth_required' }` | A webhook whose sender signs: `await ctx.webhooks.verifyStripe(\u2026)` / `verifyGitHub(\u2026)` / `await ctx.verifyHmac(\u2026)`. A scheduler: a token from a request **header** compared against `await ctx.secrets.get('server.<name>')`. A sender that does not sign: an authoritative look-up at the provider |\n| File marker | none | first line `// machine-triggered: signature check`, `// machine-triggered: token gate` or `// machine-triggered: provider look-up` |\n| Failure convention | **Return** `{ error }` (HTTP 200, the app shows it) | Rejecting a caller: **return** `{ error }`. A transient failure on a genuine delivery: **throw** \u2192 HTTP 500, so the sender delivers again. A returned `{ error }` is a 200 and acknowledges an event you did not process |\n| Duties | \u2014 | Idempotent: deliveries repeat, and after a `408` the first run may still be finishing. `ctx.data` has **no conditional write**, so \"exactly once\" cannot be built \u2014 make a repeat end in the same state, and bind side effects (mail) to the state change |\n\nKeep thrown messages generic (`throw new Error('stripe_unavailable')`): the message of a thrown error is returned to the caller in the 500 body.\n\n#### Time-triggered work: an external scheduler calls a machine-triggered action\n\nThe platform has no scheduler. The honest way out: an external scheduler (GitHub Actions `schedule`, cron-job.org, the schedule feature of the provider the user already has) calls the action over HTTPS and sends a token in a header. Tell the user that THEY set up and own that scheduler; you only build its target.\n\n```js\n// machine-triggered: token gate\n// .server/actions/daily-digest.js \u2014 called by an EXTERNAL scheduler, never by the app\nfunction sameToken(given, expected) {\n // Constant-time compare as a plain XOR loop; the sandbox has no timing-safe helper.\n if (typeof given !== 'string' || given.length !== expected.length) return false;\n let diff = 0;\n for (let i = 0; i < expected.length; i++) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i);\n return diff === 0;\n}\n\nmodule.exports = async function (ctx) {\n const expected = await ctx.secrets.get('server.scheduler_token');\n if (!expected || !sameToken(ctx.headers['x-scheduler-token'], expected)) {\n return { error: 'forbidden' }; // reject BEFORE any other secret is read or any data is touched\n }\n const mailKey = await ctx.secrets.get('server.resend_api_key');\n if (!mailKey) throw new Error('missing_server_secret');\n\n // One state document (collection `digest_state` must exist). A repeated call\n // on the same day finds the same state and sends nothing.\n const today = new Date().toISOString().slice(0, 10);\n const [state] = await ctx.data.list('digest_state', { limit: 1 });\n if (state && state.data.last_day === today) return { skipped: 'already_sent' };\n\n const res = await ctx.fetch('https://api.resend.com/emails', {\n method: 'POST',\n headers: { Authorization: `Bearer ${mailKey}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({ /* from, to, subject, html */ }),\n });\n if (!res.ok) throw new Error('mail_provider_unavailable'); // 500 \u2192 the scheduler retries\n\n if (state) await ctx.data.update('digest_state', state.doc_id, { ...state.data, last_day: today });\n else await ctx.data.create('digest_state', { last_day: today });\n return { sent: true };\n};\n```\n\nWhat the user configures in their scheduler (header names reach the action lower-cased in `ctx.headers`):\n\n```\ncurl -X POST https://<app-domain>/api/p/<project_id>/actions/daily-digest \\\n -H 'Content-Type: application/json' -H 'X-Scheduler-Token: <the value of server.scheduler_token>' -d '{}'\n```\n\nTwo overlapping calls can both pass the `last_day` check (no conditional write) \u2014 tell the user to schedule one call per period, and do not promise exactly-once delivery.\n\n**One run, many recipients: keep the state PER RECIPIENT.** The example above sends one mail, so one state document is enough. A run that mails every user must not throw at the end and leave a single \"sent today\" flag unset: the retry would mail everyone again who already got theirs. Record `last_day` per recipient right after that recipient's mail went out, skip recipients already marked for today, and throw at the end only so the scheduler retries the ones that failed. Recipients come from the app's own collection (see \"`ctx.data` Is Privileged\" \u2014 `_users` is not readable), and with more than 100 of them one call cannot cover everyone: say so instead of silently mailing the first hundred.\n\n#### Incoming webhooks: check the signature first \u2014 the platform host does the cryptography\n\nA webhook URL is public; anyone can POST to it. Pick the gate by what the SENDER offers, in this order:\n\n1. **The sender signs its deliveries \u2192 signature check. This is the default, and for Stripe and GitHub the only right answer.** The cryptography runs in the platform host: the action names the secret, the host looks it up, takes the header and the raw body from the request itself and hands back a verdict. The signing secret never enters the action, and there is no message to assemble wrongly.\n\n | The sender signs with | First statement of the action | Result |\n |---|---|---|\n | Stripe (`Stripe-Signature`) | `await ctx.webhooks.verifyStripe('server.stripe_webhook_secret')` \u2014 replay protection included: 300 s, `{ toleranceSeconds }` narrows it, widening stops at 3600 | `{ valid, reason }` |\n | GitHub (`X-Hub-Signature-256`) | `await ctx.webhooks.verifyGitHub('server.github_webhook_secret')` | `{ valid, reason }` |\n | Another HMAC over the raw body (Shopify, Paddle, \u2026) | `await ctx.verifyHmac({ secret: 'server.<name>', message, signature, algorithm, encoding })` \u2014 `algorithm` `'sha256'` (default), `'sha512'` or `'sha1'`; `encoding` `'hex'` (default) or `'base64'` | `true` / `false` |\n\n - `reason` is one of `missing_header`, `malformed_header`, `stale_timestamp`, `mismatch`, `unreadable_body`. It never says anything about the expected value; log it with `console.warn` if you like, answer the caller with `{ error: 'invalid_signature' }` only.\n - **A wrong signature is a verdict, a missing secret THROWS.** The throw becomes HTTP 500 and the sender delivers again once the owner has stored the secret \u2014 a misconfiguration must not look like a forged delivery. Do not catch it.\n - `secret` is always the NAME of a stored secret (`server.<name>`), never its value: do not `ctx.secrets.get` a signing secret.\n - `message` must be built from `ctx.rawBody` \u2014 the bytes the sender signed. `JSON.stringify(ctx.body)` is the same JSON and different bytes; it never verifies. After a valid check, read the event with `JSON.parse(ctx.rawBody)`.\n - `ctx.verifyHmac` has **no replay protection**. Where the scheme carries a timestamp (Paddle below), check its age yourself \u2014 AFTER the signature, because only a verified timestamp means anything.\n - `ctx.rawBody.length` counts UTF-16 units, not bytes (an emoji counts 2). Where a sender states a byte length, compare `new TextEncoder().encode(ctx.rawBody).length`.\n2. **The sender does not sign, or signs in a way the host does not cover \u2192 authoritative look-up.** Take **only the id** from the incoming body, fetch the event or payment from the provider with the secret key, and work **exclusively with the fetched data**. This proves the EVENT is real \u2014 it does **not** prove the REQUEST came from the provider. Therefore also: check the assignment (order id from the metadata, amount, currency, status), tolerate duplicate delivery, and throw on a transient failure. It costs one provider API call per delivery, forged ones included; the per-project concurrency cap bounds that. Mollie's payment webhook is built for exactly this (it posts `id=tr_\u2026` and nothing else). Not covered by the host: asymmetric signatures (Ed25519, ECDSA, RSA \u2014 Discord, for one) and Svix / Standard Webhooks secrets (header `svix-signature` or `webhook-signature` \u2014 Resend, Clerk), whose key is the base64-DECODED part after `whsec_` \u2014 the host uses a stored secret as the key exactly as typed. Stripe's `whsec_\u2026` is NOT one of these: Stripe uses it as text, and `verifyStripe` handles it.\n3. **Neither works \u2192 a receiver in front that verifies** (the user's own Worker, Hookdeck, Pipedream, \u2026) and then calls a machine-triggered action with a header token. Also the answer when the sender needs a reply of its own shape: Slack's URL-verification handshake wants `challenge` at the top level of the response, and an action always answers `{ ok: true, data }` \u2014 the platform function can verify Slack's signature, the action still cannot complete the handshake.\n4. **A token in the URL is NOT a substitute.** It is a bearer secret that sits in logs and dashboards.\n\n```js\n// machine-triggered: signature check\n// .server/actions/stripe-webhook.js \u2014 Stripe calls this; the platform host checks Stripe-Signature\nmodule.exports = async function (ctx) {\n // 1. Gate: header and raw body come from the request itself, the signing secret (whsec_\u2026, stored as\n // server.stripe_webhook_secret) stays in the host. A missing secret THROWS \u2192 500 \u2192 Stripe redelivers.\n const check = await ctx.webhooks.verifyStripe('server.stripe_webhook_secret');\n if (!check.valid) return { error: 'invalid_signature' }; // HTTP 200: acknowledged, no retry storm, the caller learns nothing\n\n // 2. The bytes are authentic now \u2014 parse exactly what was verified.\n const event = JSON.parse(ctx.rawBody);\n if (event.type !== 'checkout.session.completed') return { ignored: event.type };\n const session = event.data.object;\n if (session.payment_status !== 'paid') return { ignored: 'not_paid' };\n\n // 3. Check the assignment: the order must exist and match what was charged.\n const orderId = session.metadata && session.metadata.order_id;\n const order = orderId ? await ctx.data.get('orders', orderId) : null;\n if (!order) return { error: 'unknown_order' };\n if (order.data.amount_cents !== session.amount_total || order.data.currency !== session.currency) {\n return { error: 'amount_mismatch' };\n }\n\n // 4. Idempotent: a second delivery finds the same state and changes nothing.\n // Side effects (confirmation mail) belong HERE, on the transition \u2014 never above this check.\n if (order.data.status === 'paid') return { already: true };\n await ctx.data.update('orders', order.doc_id, { ...order.data, status: 'paid', stripe_event: event.id });\n return { paid: true };\n};\n```\n\nNo Stripe API call, no Stripe secret key: a verified body needs no second opinion. The owner stores the endpoint's signing secret (`whsec_\u2026`, on the webhook endpoint's page in the Stripe dashboard) in Backend \u2192 Secrets as `server.stripe_webhook_secret` \u2014 name it in your final answer, together with the URL to register: `https://<app-domain>/api/p/<project_id>/actions/stripe-webhook`.\n\nTwo recipes for `ctx.verifyHmac` \u2014 one signature over the bare body, one with a timestamp in the signed message. Other HMAC schemes differ only in header name, message and encoding; take those three from the provider's documentation, never from memory.\n\n```js\n// machine-triggered: signature check\n// .server/actions/shopify-webhook.js \u2014 X-Shopify-Hmac-Sha256: base64 HMAC-SHA256 over the raw body\nmodule.exports = async function (ctx) {\n const genuine = await ctx.verifyHmac({\n secret: 'server.shopify_webhook_secret', // the NAME \u2014 the host looks the value up\n message: ctx.rawBody, // the bytes as received, never JSON.stringify(ctx.body)\n signature: ctx.headers['x-shopify-hmac-sha256'], // a missing header is simply `false`\n encoding: 'base64',\n });\n if (!genuine) return { error: 'invalid_signature' };\n\n const payload = JSON.parse(ctx.rawBody); // authentic now\n // \u2026 handle payload by topic. Deliveries repeat: make the handling a state transition on a document you\n // can `ctx.data.get` by id (as in the Stripe example) \u2014 never \"create unless a list scan finds it\",\n // which silently stops working past 100 documents.\n return { received: ctx.headers['x-shopify-topic'] || null, id: payload.id };\n};\n```\n\n```js\n// machine-triggered: signature check\n// .server/actions/paddle-webhook.js \u2014 Paddle-Signature: ts=<unix>;h1=<hex>, signed message `${ts}:${rawBody}`\nmodule.exports = async function (ctx) {\n const header = String(ctx.headers['paddle-signature'] || '');\n const ts = (/(?:^|;)ts=(\\d{1,12})(?:;|$)/.exec(header) || [])[1];\n const h1 = (/(?:^|;)h1=([0-9a-f]+)(?:;|$)/.exec(header) || [])[1];\n const genuine = await ctx.verifyHmac({\n secret: 'server.paddle_webhook_secret', message: `${ts}:${ctx.rawBody}`, signature: h1,\n });\n if (!genuine) return { error: 'invalid_signature' };\n // verifyHmac has no replay protection: check the age yourself, AFTER the signature.\n if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return { error: 'stale_delivery' };\n\n const event = JSON.parse(ctx.rawBody); // authentic now\n // \u2026 handle event.event_type as a state transition (as in the Stripe example); deliveries repeat.\n return { received: event.event_type };\n};\n```\n\nThe look-up, for a sender that does not sign:\n\n```js\n// machine-triggered: provider look-up\n// .server/actions/mollie-webhook.js \u2014 Mollie does NOT sign this call: it posts `id=tr_\u2026`, form-encoded, and expects you to fetch the payment\nmodule.exports = async function (ctx) {\n // 1. Take ONLY the payment id from the incoming body. A form-encoded body is read from\n // ctx.rawBody (ctx.body is {} for it); everything in it is unauthenticated.\n const paymentId = new URLSearchParams(ctx.rawBody || '').get('id');\n if (typeof paymentId !== 'string' || !/^tr_[A-Za-z0-9]{1,100}$/.test(paymentId)) return { error: 'bad_event' };\n\n // 2. Gate: fetch the payment from Mollie. A made-up id does not exist there.\n const apiKey = await ctx.secrets.get('server.mollie_api_key');\n if (!apiKey) throw new Error('missing_server_secret'); // 500 \u2192 Mollie redelivers once the key is stored\n const res = await ctx.fetch(`https://api.mollie.com/v2/payments/${paymentId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.status === 404) return { error: 'unknown_payment' }; // reject: not a real payment (Mollie asks for a 200 here too)\n if (!res.ok) throw new Error('mollie_unavailable'); // transient \u2192 500 \u2192 Mollie redelivers\n const payment = await res.json(); // from here on use ONLY `payment`, never the incoming body\n\n if (payment.status !== 'paid') return { ignored: payment.status };\n\n // 3. Check the assignment: the order must exist and match what was charged.\n // Mollie gives the amount as { value: '49.00', currency: 'EUR' } \u2014 store it that way when the payment is created.\n const orderId = payment.metadata && payment.metadata.order_id;\n const order = orderId ? await ctx.data.get('orders', orderId) : null;\n if (!order) return { error: 'unknown_order' };\n const charged = payment.amount || {};\n if (order.data.amount_value !== charged.value || order.data.currency !== charged.currency) {\n return { error: 'amount_mismatch' };\n }\n\n // 4. Idempotent: a second delivery finds the same state and changes nothing.\n // Side effects (confirmation mail) belong HERE, on the transition \u2014 never above this check.\n if (order.data.status === 'paid') return { already: true };\n await ctx.data.update('orders', order.doc_id, { ...order.data, status: 'paid', mollie_payment: payment.id });\n return { paid: true };\n};\n```\n\n**The assignment check fails CLOSED \u2014 in both payment examples.** Compare strictly, exactly as above: an event without `amount_total` or `currency`, a payment without an amount, or an order without a recorded amount, is a MISMATCH. Do not soften it \"defensively\" into `if (typeof session.amount_total === 'number' && \u2026)` \u2014 that makes the one check standing between a replayed event and a paid order optional. A valid signature does not make this check optional either: it proves who sent the event, not which order it pays.\n\n`ctx.data.update` REPLACES the document's `data` \u2014 always spread the current `data` into the update, as above. The checkout action that creates the Stripe session or the Mollie payment must put `order_id` into its `metadata`; without it the webhook cannot check the assignment.\n\n### Authoring protocol (engine flow)\n\n1. Read the preamble's `Managed actions` block. If your target name is already deployed with the right shape, skip to step 3.\n2. **Upsert:** write the action source to a local temp file, then `agentful-managed-actions upsert <project_id> <name> <file_path>`. The validator runs on both the public PUT-files path and this CLI; same rejection rules.\n3. Write the frontend `fetch('/api/p/<project_id>/actions/<name>', { method: 'POST', body: JSON.stringify(payload) })`. Include `Content-Type: application/json` on POSTs.\n4. Test once with `agentful-managed-actions invoke <project_id> <name> --body '{\"\u2026\":\"\u2026\"}'` to confirm the deployment landed. **Read the result in two layers:** HTTP 200 + `{ok:true,data:\u2026}` only proves the action returned without throwing \u2014 the platform wraps whatever the action returns, so `data` may itself be `{error:'auth_required'}` or another app-level failure. An unauthenticated CLI invoke of a `ctx.user`-gated action therefore proves deployment only, never business success; the business path needs a real end-user JWT (log in through the app, or the platform E2E harness).\n\n### Action-invocation error codes (response body)\n\n| `error.code` | HTTP | What it means | What to do |\n|---|---|---|---|\n| `action_not_found` | 404 | The action with that name doesn't exist | Upsert it first. |\n| `action_validation_failed` | 400 | Validator rejected the source (size, filename, or require/import/process usage \u2014 the sandbox has no module system) | Fix per `reason` field; do not retry. |\n| `concurrency_exceeded` | 429 | Per-project cap reached | Backoff + retry, or surface to user. |\n| `action_loop_detected` | 508 | `X-Action-Depth >= 5` \u2014 too many self-calls in a chain | Refactor; you cannot self-recurse beyond depth 5. |\n| `timeout` | 408 | Action was still waiting when its 5 s budget ran out. Calls it had already started may still complete | Move heavy work out of the action or break into smaller calls; make the action safe to repeat. |\n| `body_too_large` | 413 | Request body > 1 MB | Trim the payload. |\n| `action_too_large` | 413 | Action source file > 256 KB | Split into multiple actions. |\n| `runtime_error` | 500 | Action threw at runtime | Read the message; common causes are unhandled rejections, missing `await`, or accessing undefined ctx fields. |\n\n### Anti-patterns\n\n- \u274C Putting a Stripe / OpenAI / Resend API key in frontend code. It must live in `ctx.secrets`.\n- \u274C `ctx.secrets.STRIPE_SECRET_KEY` (property access). The secrets API is `await ctx.secrets.get('server.stripe_secret_key')`; property access is silently `undefined`, and uppercase/unprefixed names cannot be stored at all.\n- \u274C `ctx.user.sub`. The verified user object is `{ id, email, pid }` \u2014 the JWT `sub` claim arrives as `ctx.user.id`.\n- \u274C Treating `ok: true` on an action response as \"the action succeeded\". The outer `ok` means \"it ran\"; your `{ error: '\u2026' }` sits inside `data`. A client that unwraps only once turns every action error into a phantom success.\n- \u274C An action that reads secrets or writes data without a caller gate as its first statement \u2014 `ctx.user`, or (only in a file marked `// machine-triggered: \u2026`) a signature check / header token / provider look-up. Actions are publicly invokable; your gate is the only authorization.\n- \u274C Promising a cron job, a nightly run or a background job. There is no scheduler; an action runs only when called.\n- \u274C Accepting a webhook's body as true before the gate has passed. Where the sender signs, the first statement is `ctx.webhooks.verifyStripe` / `verifyGitHub` / `ctx.verifyHmac`; where it does not, the provider look-up \u2014 and then only the fetched data counts.\n- \u274C A provider look-up for a Stripe or GitHub webhook. Both sign; the look-up proves the event, not the sender, and costs an API call per delivery.\n- \u274C Building an HMAC or a hash by hand, or probing for `crypto`. The sandbox has none; `ctx.verifyHmac` runs in the platform host and compares in constant time.\n- \u274C `JSON.stringify(ctx.body)` as the signed message, or `await ctx.secrets.get(\u2026)` for a signing secret. The message is `ctx.rawBody`; the secret is passed by NAME.\n- \u274C A caller token in the URL (`?token=\u2026`). URLs end up in logs and dashboards; send it in a header.\n- \u274C Returning `{ error }` from a machine-triggered action for a TRANSIENT failure. A returned value is always HTTP 200 and acknowledges the delivery; **throw** so the sender gets a 500 and retries.\n- \u274C Calling `fetch('/api/p/.../actions/foo')` without first running `agentful-managed-actions upsert`. Will return 404.\n- \u274C Recursive actions calling themselves to \"spread work\". Will trip the depth guard at 5.\n- \u274C Using bare `fetch` instead of `ctx.fetch` from inside an action. The depth header won't propagate; you bypass the loop guard.\n- \u274C Naming actions `Hello.js`, `_internal.js`, or `actions/sub/foo.js`. The validator rejects (uppercase, leading underscore, subdirectory).\n- \u274C Hardcoding the API base URL into the action's `ctx.fetch` calls to other actions. Use a relative path or the project's own API origin.\n\n### `ctx.data` Is Privileged \u2014 It Bypasses `access_rule`\n\n`ctx.data` inside an action talks to the database **directly, with no\n`access_rule` enforcement**. It is a project-scoped, owner-level surface \u2014 the\nopposite of the `/data/*` end-user route:\n\n- It reads and writes **every** document in **every** collection, regardless of\n whether that collection is `owner`, `authenticated`, or `public`.\n- It does **not** check `created_by`. An action can read one user's `owner`\n docs and write into another user's.\n- Documents created via `ctx.data.create` are attributed `created_by: \"action\"`,\n never to an end-user. If you need owner attribution, store the owner's id in\n the document `data` yourself (e.g. `{ user_id: ctx.user.id, ... }`) and\n filter on it.\n- `ctx.data.list(collection, { limit })` returns a **plain array** of docs\n (`[{ doc_id, data, created_by, created_at }]`, NOT `{ documents: [...] }`),\n caps at 100, and does **no server-side filtering** \u2014 you filter in JS. For\n data sets that can exceed 100 rows, store an explicit owner/lookup field and\n design around the cap; do not assume `list` returns everything.\n\n- **End-user accounts are NOT readable through `ctx.data`.**\n `ctx.data.list('_users')` returns rows whose `data` is EMPTY: e-mail, display\n name and role are account fields and are not exposed to action code. Code\n that reads `u.data.email` gets `undefined` for every user and silently does\n nothing. The only address an action ever sees is `ctx.user.email` \u2014 the\n caller's own. When an action must later reach users who are NOT the caller\n (a digest, a notification from a machine-triggered action), the app keeps its\n own collection: a **user-authorized** action writes\n `{ user_id: ctx.user.id, email: ctx.user.email }` \u2014 taken from the verified\n JWT, never from `ctx.body`, or anyone could make the app mail strangers at\n the owner's cost \u2014 when the user signs in, and the machine-triggered action\n reads that collection. Never write to or delete from `_users` via `ctx.data`.\n\nThis is the intended mechanism for trusted/admin work. The trade-off: an action\nis only as safe as its own checks. **Always verify `ctx.user` before any\ncross-user read or write.**\n\n### RBAC & Secure Role Assignment (`owner` + Action)\n\nThe managed DB has **no role concept and no field-level validation**. On the\nend-user `/data/*` route the client controls the entire document body \u2014\nincluding any `role` field. Design around two facts:\n\n1. **Privilege escalation is possible by default.** If a `profiles` collection\n is `authenticated` or `owner`, a client can register and POST\n `{ role: \"admin\" }` for themselves. `owner` does NOT stop this \u2014 the user\n owns their own profile.\n2. **`owner` blocks admins too.** An `owner` collection correctly hides a\n client's data from other clients, but an admin also cannot read it over\n `/data/*`. Admin access must go through an action using `ctx.data`.\n\nSecure pattern \u2014 keep `role` server-owned and gate every change behind an\naction that verifies the **caller** is already an admin:\n\n```js\n// .server/actions/set-role.js \u2014 upsert BEFORE calling it from the client\nmodule.exports = async function (ctx) {\n if (!ctx.user) return { error: 'auth_required' };\n // 1. Verify the CALLER is an admin (ctx.data ignores access_rule, so this\n // works even though `profiles` is `owner`).\n const all = await ctx.data.list('profiles', { limit: 100 });\n const me = all.find(d => d.data.user_id === ctx.user.id);\n if (!me || me.data.role !== 'admin') return { error: 'forbidden' };\n // 2. Validate input, then apply to the target.\n const { target_user_id, role } = ctx.body || {};\n if (!['admin', 'member', 'client'].includes(role)) return { error: 'bad_role' };\n const target = all.find(d => d.data.user_id === target_user_id);\n if (!target) return { error: 'not_found' };\n await ctx.data.update('profiles', target.doc_id, { ...target.data, role });\n return { ok: true };\n};\n```\n\nRules for this pattern:\n\n- The client UI must **never** write the `role` field over `/data/*`. On\n self-registration, create the profile without `role` (or force a non-privileged\n default in the action) \u2014 never trust a client-sent role.\n- \"Owner OR admin\" **reads** (an admin viewing any client's invoices) use the\n same shape: keep the collection `owner`, expose admin access through an action\n that verifies `ctx.user` is an admin, then uses `ctx.data` to fetch across users.\n- **Bootstrapping the first admin:** see **First Admin \u2014 mode-correct\n protocol** below. Never invent a client-reachable route for it.\n- The 100-row `ctx.data.list` cap applies: if `profiles` can exceed 100 rows,\n this scan-in-JS lookup is unreliable. Until server-side filtering exists,\n store role lookups in a bounded collection or key admins by a known id set.\n\n## First Admin \u2014 mode-correct protocol\n\nWhen the app needs an admin (dashboard, moderation, `admin`-ruled collections),\nask the builder **\"How should the first admin account be created?\"** and offer\nONLY these options \u2014 they map to the platform's `_users.role` system\n(`user`/`admin`), which is what the `admin` access rule checks:\n\n1. **Fixed admin email (recommended).** Ask the builder for the address, then\n run:\n ```\n agentful-auth-config set $PROJECT_ID '{\"admin_email\":\"chef@firma.de\"}'\n ```\n Whoever registers (or later logs in) with exactly that address is promoted\n to `role: admin` **server-side, only after email verification** \u2014 no code\n needed in the app.\n2. **Manual via Data Manager.** The builder opens **Backend \u2192 Data Manager \u2192\n `_users` tab**, selects the registered user and sets the `role` dropdown to\n `admin` (the `verified` flag can also be set there if a mail never arrived).\n\n### `agentful-auth-config` CLI\n\n- `agentful-auth-config get $PROJECT_ID` \u2014 current `config.auth` state\n (`auth: null` = hardened pipeline not activated yet \u2192 registering works\n legacy-style without verification) plus `email_infrastructure`\n (`\"\"` = builder has not chosen one; verification mails will fail with\n `email_infra_unconfigured` until they pick one in project settings).\n- `agentful-auth-config set $PROJECT_ID '<json>'` \u2014 merge into `config.auth`.\n Keys: `methods` (subset of `email|google|oidc`; the server clamps against\n the org allowlist \u2014 verify the result in the response), `require_verified_login`\n (bool; default true once auth is configured), `admin_email`, `language`\n (`de`|`en`, auth-mail language).\n- Setting ANY key activates the hardened pipeline (verification mails +\n verified-login gate). Before activating it, run `get` and make sure\n `email_infrastructure` is not empty \u2014 otherwise tell the builder to choose\n Agentful Email or BYO in project settings first.\n- Email infrastructure is NOT settable via this CLI by design (audited\n builder decision, DE-data-region notice).\n\n**NEVER offer \"run SQL\" / \"insert into the database manually\" for\n`database.mode == managed` \u2014 there is no SQL surface; the managed DB is not a\nSQL database.** SQL-based instructions apply only to BYO-Supabase projects\n(different skill, different mode).\n\nPrefer the platform `_users.role` + `admin` access rule over inventing an\napp-level `profiles.role` system when the requirement is just \"one admin can\nsee/manage everything\" \u2014 the profiles-RBAC pattern above is for MULTI-role\napps (admin/member/client) that need roles beyond `user`/`admin`.\n\n---\n\n## Managed AI (platform-brokered LLM calls)\n\nManaged AI lets a generated app call an LLM through the platform:\n`/api/p/{PROJECT_ID}/ai/*`. The provider API key is stored **server-side by\nthe platform** (saved once by the end-user, or configured by the app owner)\nand is resolved only inside the platform broker \u2014 it never reaches the\nbrowser, the app bundle, actions, or the managed database.\n\n### Availability gate (fail-closed)\n\nManaged AI exists for this app **only** when the `[BACKEND STATUS]` preamble\nannounces `Managed AI: AVAILABLE` (equivalently: `agentful-backend-state`\nshows `managed_ai.available: true`). It also requires `database.mode ==\nmanaged` \u2014 the routes authenticate with the same end-user JWT as `/data/*`.\n\n**If it is NOT announced, the capability does not exist on this platform for\nthis app.** In that case:\n\n- Build the rest of the app; render AI features as clearly-labeled sample\n output (marked as sample), and tell the user plainly which platform\n capability is missing. Never wire a fake.\n- **NEVER improvise a substitute.** No collection that stores API keys\n (plain or \"encrypted\" \u2014 `api_key_enc` is the historical production\n failure), no cryptography in actions, no action that accepts an end-user's\n API key, no provider keys in `localStorage`/`sessionStorage`, no direct\n provider `fetch` from the browser with a user's key.\n- The one sanctioned alternative \u2014 only when `server.mode == managed` and\n the **owner** brings the key \u2014 is a Managed Action using\n `await ctx.secrets.get('server.openai_key')` + `ctx.fetch` to the\n provider (see Managed Actions above). That is app-funded, server-side AI;\n it must never accept or store end-user keys.\n\n### Two payment modes \u2014 decided at RUNTIME, not at build time\n\n`GET /ai/credentials` returns a `mode` field. Generated apps read it at\nruntime and adapt the UI; never hardcode the mode:\n\n- **`user_required`** (default): every logged-in end-user saves their own\n provider API key once; their key pays for their requests. UI: a small\n \"AI settings\" area with provider key form + presence/hint display.\n- **`project_shared`**: the app owner provides the provider access; end\n users never see a key form. UI: label the feature as provided by the app\n (e.g. \"AI features are provided by this app\") and handle the quota\n errors below. `PUT` answers `409 mode_project_shared` in this mode \u2014\n the app must not offer saving personal keys (existing personal keys stay\n listed and deletable).\n\nBoth modes require a **logged-in end-user** (`Authorization: Bearer` token\nfrom `auth/login`). Every `/ai/*` route answers `401 auth_required` without\nit \u2014 gate all AI UI behind login.\n\n### API surface\n\nBase: `/api/p/{PROJECT_ID}/ai/` \u2014 same envelope as the data API\n(`{ok:true, data:{\u2026}}` / `{ok:false, error:{code, message}}`).\n\n- `GET ai/credentials` \u2014 mode + stored-key metadata. **Never returns a key.**\n - `user_required`: `{mode:'user_required', credentials:[{provider, status,\n key_hint, created_at, updated_at, last_validated_at}]}`\n - `project_shared`: `{mode:'project_shared', provider, models:[\u2026],\n project_key_present, credentials:[\u2026]}`\n - `?include=providers` (user_required only): adds `providers:[{id, label,\n models:[\u2026]}]` \u2014 the live server-side allowlists. **Render provider and\n model pickers from this registry, never from a hardcoded list**: the\n platform adds models over time and a baked-in list ages with the app.\n Fetch it once per session (it is a few hundred model ids), cache it in\n state, and fall back to one cheap documented default per provider (table\n below) if the field is absent. In `project_shared` the response's pinned\n `provider`/`models` ARE the offer \u2014 no registry there.\n- `PUT ai/credentials/{provider}` \u2014 body `{api_key: '\u2026'}`. The platform\n validates the key against the real provider BEFORE storing; an invalid key\n is rejected (`credential_invalid`) and nothing is stored. Success returns\n metadata only: `{provider, status:'active', key_hint, updated_at}`.\n- `DELETE ai/credentials/{provider}` \u2014 `{provider, deleted:true}`.\n Idempotent; works in every mode.\n- `POST ai/generate` \u2014 body `{provider, model, prompt, system?, max_tokens?}`\n \u2192 `{text, provider, model, credential_mode, usage:{input_tokens,\n output_tokens}}`. Single-turn, non-streaming. In `project_shared`, omit\n `provider` (or send exactly the pinned one \u2014 any other id is refused, not\n redirected).\n\nBounded limits (enforced server-side): `prompt` \u2264 24000 chars, `system` \u2264\n4000 chars, `max_tokens` clamped to \u2264 4096 (default 1024), per-user request\nbudget (default 20 generate/min, 5 credential saves/min, 2 concurrent\ngenerations), provider timeout ~25 s. For chat features, keep conversation\nhistory client-side and include only the recent turns in `prompt`, trimming\nto stay under the cap.\n\n### Providers\n\n| provider id | example allowlisted models |\n|---|---|\n| `openai` | `gpt-4o-mini`, `gpt-4o`, `gpt-4.1-mini` |\n| `anthropic` | `claude-3-5-haiku-latest`, `claude-sonnet-4-0` |\n| `google` | `gemini-2.0-flash`, `gemini-2.5-flash` |\n| `deepseek` | `deepseek-chat`, `deepseek-reasoner` |\n| `zai` | `glm-5.3-flash`, `glm-5.3` |\n\nAdditional OpenAI-compatible providers are registered (`openrouter`,\n`fireworks-ai`, `moonshotai`, `nebius`, `baseten`, `siliconflow`). Models are\nallowlisted server-side per provider: an unknown provider or model returns\n`provider_not_allowed` / `model_not_allowed` **with the allowed list in the\nerror message** \u2014 surface that message. The table above lists stable\nDEFAULTS, not the full allowlists: the full, current lists come from\n`GET ai/credentials?include=providers` (see above) \u2014 build pickers from\nthat registry so the app never ages, and validate a stored model against it\n(fall back to the provider default when a stored id is no longer listed).\nPick ONE provider with a cheap default model per feature (e.g.\n`openai` + `gpt-4o-mini`) unless the user asks for a choice.\n\n### Client pattern\n\nAppend to the shared client (`src/lib/data.js` \u2014 reuses `jsonOrThrow` and\n`authHeader` from above):\n\n```js\nexport const ai = {\n // Runtime mode signal \u2014 call after login, before rendering AI UI.\n async status() {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials`, {\n headers: { ...authHeader() },\n })); // -> {mode, credentials, provider?, models?, project_key_present?}\n },\n async saveKey(provider, apiKey) {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials/${provider}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ api_key: apiKey }),\n })); // -> {provider, status:'active', key_hint, updated_at} \u2014 never the key\n },\n async deleteKey(provider) {\n return jsonOrThrow(await fetch(`${BASE}/ai/credentials/${provider}`, {\n method: 'DELETE', headers: { ...authHeader() },\n })); // -> {provider, deleted:true}\n },\n async generate({ provider, model, prompt, system, max_tokens }) {\n return jsonOrThrow(await fetch(`${BASE}/ai/generate`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeader() },\n body: JSON.stringify({ provider, model, prompt, system, max_tokens }),\n })); // -> {text, provider, model, credential_mode, usage}\n },\n};\n```\n\nMode-adaptive rendering, in every app with an AI feature:\n\n```js\nconst aiState = await ai.status();\nif (aiState.mode === 'project_shared') {\n // No key form. Label: \"AI features are provided by this app.\"\n // Generate with the pinned provider/models from aiState.\n} else {\n // user_required: render key settings (presence/hint) + generate UI\n // only when a credential for the chosen provider exists.\n}\n```\n\n### Settings UI rules (user_required)\n\n1. The key input is `type=\"password\"`. After a successful `saveKey`, **clear\n the input** and render the returned `key_hint` (last 4 chars) \u2014 the app\n never displays, stores, or echoes the full key anywhere.\n2. The key exists in exactly one place in the app: the in-flight `PUT` body.\n Never in `localStorage`/`sessionStorage`, never in app state beyond the\n form field, never in a collection, never in an action payload.\n3. Presence is what `GET ai/credentials` returns \u2014 render provider,\n `key_hint`, `updated_at`, and a delete button per stored credential.\n4. On `credential_invalid` during save: tell the user WHICH provider\n rejected the key and that nothing was stored (\"does the key match the\n selected provider?\"); do not retry automatically. Keep this message\n distinct from the runtime one (stored key expired/revoked \u2192 save a new\n key) \u2014 one generic text for both hides provider mismatches.\n5. The key save PUTs to the provider **currently selected in the form**,\n and persists that provider/model choice in the same action \u2014 never to a\n previously stored provider default. A key sent to yesterday's provider\n fails validation against the wrong API and reads like a broken key\n (real incident: FeedSum, 2026-09-01).\n6. Point users to their provider dashboard to create a key; the app never\n asks for anything but the key string.\n\n### Error code \u2192 user-facing handling\n\n| `error.code` | HTTP | Meaning | What the app does |\n|---|---|---|---|\n| `auth_required` | 401 | Not logged in | Show login; AI UI is login-gated. |\n| `feature_unavailable` | 503 | Platform AI is switched off | \"AI features are temporarily unavailable.\" Platform state \u2014 do not retry in a loop, nothing to fix in code. |\n| `no_credential` | 404 | user_required: no key stored for this provider | Open the AI settings / key form. |\n| `credential_invalid` | 400 | Provider rejected the key (save or generate) | user_required: ask for a new key. project_shared: \"Contact the app owner.\" |\n| `invalid_key_format` | 400 | Key string malformed (8\u2013512 chars, no whitespace) | Inline form validation message. |\n| `mode_project_shared` | 409 | PUT while the app provides AI | Hide/disable the key form (the mode signal was missed \u2014 re-check `status()`). |\n| `project_key_missing` | 503 | project_shared: owner has not configured the key yet | \"The app owner has not configured AI access yet.\" |\n| `project_quota_exhausted` | 429 | project_shared: app's daily AI budget used up | \"The app's daily AI budget is used up \u2014 resets at midnight UTC.\" |\n| `provider_not_allowed` / `model_not_allowed` | 400 | Provider/model not in the server allowlist | Surface the message (it names what IS allowed); fix the generated default. |\n| `missing_prompt` / `prompt_too_large` | 400/413 | Empty prompt / over the 24k-char cap | Trim client-side before sending. |\n| `rate_limited` / `concurrency_exceeded` | 429 | Per-user budget hit | \"Please wait a moment and try again.\" No auto-retry loops. |\n| `provider_rate_limited` | 429 | The provider rate-limited the key | Same message; the key's own quota is exhausted. |\n| `provider_timeout` | 504 | Provider took > ~25 s | \"The AI provider did not answer in time \u2014 try again.\" |\n| `provider_unreachable` / `provider_rejected` / `provider_error` | 502 | Provider-side failure | \"The AI provider had a problem \u2014 try again later.\" |\n| `credential_unreadable` | 500 | Stored credential cannot be decrypted | user_required: delete the key and save it again. project_shared: contact the app owner. |\n| `ai_config_invalid` | \u2014 | App's AI configuration is broken | \"Contact the app owner.\" |\n\n### project_shared \u2014 owner setup (tell the BUILDER, do not generate)\n\n`project_shared` is configured by the app **owner**, not by generated code:\nthe backend declaration (`.agentful/backend.json`, applied via `agentful\npush`) carries `\"ai\": {\"credential_mode\": \"project_shared\", \"provider\":\n\"openai\", \"daily_request_limit\": 500}`, and the owner stores the paying key\nin **Backend \u2192 Secrets** as `server.ai_<provider>_key` (e.g.\n`server.ai_openai_key`). Until the key is stored, `generate` answers\n`project_key_missing` \u2014 the declaration alone activates nothing. There is no\nengine CLI for this: when the user wants \"the app provides AI for all\nusers\", explain those two owner steps and generate the mode-adaptive UI \u2014\nwhich is correct in both modes anyway.\n\n### Managed AI vs. owner-key actions \u2014 pick the right lane\n\n- **End-user-facing text generation** (chat box, \"summarize this\",\n writing aids) \u2192 Managed AI routes. In `user_required` each user's key\n pays; in `project_shared` the owner pays with a mandatory daily quota.\n- **Server-side AI inside app logic** (a digest the user triggers, enrichment\n before storing a document) with the OWNER's key \u2192 Managed Action with\n `ctx.secrets` + `ctx.fetch` (works independently of Managed AI\n availability).\n- **Never**: an action that receives an end-user's API key in its body, an\n action that proxies \"bring your own key\" traffic, or any app-built key\n storage. End-user keys are exclusively a platform concern.\n\n### Anti-patterns (Managed AI)\n\n- \u274C A `settings`/`api_keys` collection with an `api_key` or `api_key_enc`\n field \u2014 this is the FeedSum production incident. End-user keys never\n touch the managed database, in any encoding.\n- \u274C Hand-rolled cryptography anywhere (actions have no crypto primitive\n by design; the platform broker owns key handling).\n- \u274C Saving the key in `localStorage`/`sessionStorage` or app state \"so the\n user doesn't have to re-enter it\" \u2014 presence comes from `GET\n ai/credentials`, the key itself is never needed again client-side.\n- \u274C Calling `api.openai.com` (or any provider) directly from the browser\n with a user's key.\n- \u274C Rendering a key form without checking `mode` first, or hardcoding\n `project_shared` texts into a `user_required` app.\n- \u274C Retrying `credential_invalid`, `feature_unavailable`, or 429s in a\n loop.\n- \u274C Offering every provider/model in a giant dropdown. One provider, one\n default model, unless the user asks.\n",
7502
7691
  "nextjs-scaffold": '---\nname: nextjs-scaffold\ndescription: Next.js 15 static export scaffold with React 19, TypeScript, and Tailwind CSS v4 generated manually without create-next-app. Use only for explicit Next.js requests or justified static export app needs.\n---\n\n# Next.js Scaffold\n\n## Stack Briefing\n\nNext.js 15 **static export** (`output: \'export\'`) + React 19 + TypeScript +\nTailwind v4, written manually (never `create-next-app`). Static export only \u2014\nno server actions, API routes, dynamic server rendering, or image optimization\ndependency. Build produces `out/` (not `dist/`). Use only for explicit Next or\njustified static multi-page React needs.\n\n## When To Use\n\nUse this skill when `selected_stack` is Next.js. Prefer Next.js only when the user explicitly asks for Next.js, asks for Next-specific features, or needs a React app architecture with static export and file-based routing.\n\nDo not use Next.js for a simple landing page or portfolio unless requested.\n\n## When Not To Use\n\n- Simple landing pages/portfolios \u2192 `vanilla-scaffold`.\n- General React app UI without Next-specific needs \u2192 `react-scaffold`.\n- Anything needing server actions, API routes, or SSR \u2014 unsupported here.\n\n## HARD STOP: Never Re-Scaffold An Existing App\n\nBefore writing ANY scaffold file, check the workspace. If `package.json` (or\nan existing `index.html` app shell for vanilla projects) and source files\nalready exist, this project already has an app \u2014 scaffolding is DONE and this\nskill must not overwrite it. Read the existing entry points and source tree\nfirst, then build ON TOP of the existing files: keep the entry points,\nrouting, and dependency choices already in place. Overwriting the scaffold\nfiles on an existing project destroys the user\'s app (this happened in\nproduction). If the existing code seems inconsistent with the request, ask\nthe user \u2014 never replace silently.\n\n## Required File Shape\n\nThis is the shape a Next.js project has here. Depending on how the project\nstarted, these files may already exist in the workspace \u2014 check first:\n\n```text\npackage.json build = `next build`; `npm run verify` runs the full gate\nnext.config.ts output: \'export\', trailingSlash, images.unoptimized\ntsconfig.json paths "@/*" -> ./src/*\neslint.config.mjs the only ESLint config in the project\npostcss.config.mjs\nnext-env.d.ts\npublic/\n favicon.svg keep it \u2014 without a favicon every page logs a 404\nsrc/\n app/\n layout.tsx owns <html>/<body> and the metadata\n page.tsx the root route: this IS the landing page\n globals.css THE global stylesheet \u2014 Tailwind by default\n.gitignore\n```\n\n**Empty workspace** \u2014 create exactly this shape.\n\n**Files already present** \u2014 **edit them, do not recreate them.** A project\nfrom `create-next-app` is close to this already; the usual gaps are the\n`Create Next App` metadata in `layout.tsx`, the demo SVGs in `public/`, and\nan `app/` directory at the root instead of under `src/`.\n\nThe build output is `out/`, not `dist/` \u2014 that is expected and the platform\naccepts it. Do not rename it.\n\nDo not use `next/font/google` by default because it can add build-time network dependency. Use system fonts unless the user explicitly requests custom fonts.\n\n## package.json\n\n```json\n{\n "name": "project-name",\n "private": true,\n "version": "0.1.0",\n "scripts": {\n "dev": "next dev",\n "build": "next build",\n "start": "next start",\n "verify": "tsc --noEmit && eslint . --max-warnings 0 && next build"\n },\n "dependencies": {\n "next": "16.2.9",\n "react": "19.2.4",\n "react-dom": "19.2.4"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4",\n "@types/node": "^20",\n "@types/react": "^19",\n "@types/react-dom": "^19",\n "eslint": "^9",\n "eslint-config-next": "16.2.9",\n "tailwindcss": "^4",\n "typescript": "^5"\n }\n}\n```\n\n## next.config.ts\n\n```ts\nimport type { NextConfig } from \'next\'\n\nconst nextConfig: NextConfig = {\n // Static export \u2014 the platform serves files, there is no Next.js server.\n output: \'export\',\n // Emits `about/index.html` instead of `about.html`, which a plain static\n // host can resolve without rewrite rules.\n trailingSlash: true,\n // next/image needs a server to optimise; without this the export fails.\n images: {\n unoptimized: true,\n },\n}\n\nexport default nextConfig\n```\n\n## postcss.config.mjs\n\n```js\nconst config = {\n plugins: {\n \'@tailwindcss/postcss\': {},\n },\n}\n\nexport default config\n```\n\n## App Files\n\n`src/app/layout.tsx`:\n\n```tsx\nimport type { Metadata } from \'next\'\nimport \'./globals.css\'\n\nexport const metadata: Metadata = {\n title: \'Replace this title\',\n description: \'Replace this with a one-sentence description of the site.\',\n icons: { icon: \'/favicon.svg\' },\n}\n\nexport default function RootLayout({\n children,\n}: Readonly<{\n children: React.ReactNode\n}>) {\n return (\n <html lang="en" className="h-full antialiased">\n <body className="min-h-full flex flex-col">{children}</body>\n </html>\n )\n}\n```\n\n`src/app/globals.css`:\n\n```css\n@import "tailwindcss";\n\n/* Design tokens. Tailwind v4 turns every entry here into a utility\n (--color-brand -> bg-brand/text-brand), so restyle the project by editing\n these values rather than sprinkling hex codes through the components. */\n@theme {\n --color-brand: #0070f3;\n --color-brand-contrast: #ffffff;\n --color-surface: #ffffff;\n --color-surface-muted: #f5f5f7;\n --color-ink: #111827;\n --color-ink-muted: #6b7280;\n --radius-card: 0.75rem;\n}\n\n:root {\n color-scheme: light;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n --app-bg: #ffffff;\n --app-fg: #111827;\n}\n\n/* An opaque background on html, body is mandatory \u2014 the page is never\n transparent. Keep this rule when you restyle. */\nhtml, body {\n margin: 0;\n min-height: 100%;\n min-width: 320px;\n background: var(--app-bg);\n color: var(--app-fg);\n}\n\n/* Keyboard focus baseline. Components may override it, but never remove it:\n the platform appends a generic fallback to any project whose CSS contains\n no `:focus` rule at all, and a real one belongs here. */\n:focus-visible {\n outline: 2px solid var(--color-brand, currentColor);\n outline-offset: 2px;\n}\n```\n\n## TypeScript Config\n\n`tsconfig.json`:\n\n```json\n{\n "compilerOptions": {\n "target": "ES2017",\n "lib": ["dom", "dom.iterable", "esnext"],\n "allowJs": true,\n "skipLibCheck": true,\n "strict": true,\n "noEmit": true,\n "esModuleInterop": true,\n "module": "esnext",\n "moduleResolution": "bundler",\n "resolveJsonModule": true,\n "isolatedModules": true,\n "jsx": "react-jsx",\n "incremental": true,\n "plugins": [{ "name": "next" }],\n "paths": { "@/*": ["./src/*"] }\n },\n "include": [\n "next-env.d.ts",\n "**/*.ts",\n "**/*.tsx",\n "**/*.mts",\n ".next/types/**/*.ts",\n ".next/dev/types/**/*.ts"\n ],\n "exclude": ["node_modules"]\n}\n```\n\n`next-env.d.ts`:\n\n```ts\n/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n```\n\n## Routing Rules\n\nUse file-based routing under `src/app/` with static export. Do not add\n`middleware`, dynamic server routes, or `generateStaticParams` that depend on a\nserver. All routes must be statically renderable.\n\n## Data And Backend Rules\n\n- No API routes or server actions (static export forbids them). For data/auth,\n use the platform backend via client `fetch()` \u2014 respect the STOP gate.\n- `images.unoptimized: true` is required; do not add the image optimization\n server dependency.\n\n## Asset Path Rules\n\n- Keep output host-agnostic; reference the project\'s own assets/routes\n relatively. Do not set an absolute `assetPrefix`/`basePath`. No `<base>` tag.\n\n## Common Failure Modes\n\n- Adding server actions/API routes \u2192 static export build fails.\n- Using `next/font/google` \u2192 build-time network dependency.\n- Renaming `out/` to `dist/` or adding postbuild move scripts.\n- Optimized `<Image>` without `unoptimized: true`.\n\n## ESLint Config\n\nEvery scaffold ships `eslint.config.mjs` (flat config) so the platform lint\ngate and the live ESLint diagnostics work from the very first turn. Next uses\n`eslint-config-next` rather than the hand-rolled rule set of `react-scaffold`\n\u2014 measured 2026-08-04, it covers strictly more:\n\n| canary | `eslint-config-next` |\n| --- | --- |\n| `useEffect(() => setV(item), [item])` | error \u2014 *"Calling setState synchronously within an effect"* (the react-scaffold class) |\n| `<img src="/x.png">` | error \u2014 `@next/next/no-img-element` (which the hand-rolled config never sees) |\n\n`eslint.config.mjs`:\n\n```js\nimport { defineConfig, globalIgnores } from "eslint/config";\nimport nextVitals from "eslint-config-next/core-web-vitals";\nimport nextTs from "eslint-config-next/typescript";\n\nconst eslintConfig = defineConfig([\n ...nextVitals,\n ...nextTs,\n // Override default ignores of eslint-config-next.\n globalIgnores([\n // Default ignores of eslint-config-next:\n ".next/**",\n "out/**",\n "build/**",\n "next-env.d.ts",\n ]),\n]);\n\nexport default eslintConfig;\n```\n\nThe platform runs `npx eslint . --max-warnings 0` before every build, so any\nviolation blocks the build. Do not add or remove rules, and never "fix" a\nviolation with `eslint-disable` or config edits \u2014 fix the code.\n\n## Verification\n\nRun install, then the full platform verification contract (see the build\nagent\'s "Build And Preview Rules"): typecheck, `npx eslint . --max-warnings 0`\n(the scaffold ships `eslint.config.mjs`, so this check ALWAYS applies and\ndecides acceptance), `npm run\nbuild`, and finally verify that `out/index.html` exists. Do not rename `out/`\nto `dist/`. `npm run build` alone is not what the platform gate checks \u2014 and\nNext\'s own build only surfaces lint when configured to. Never make the lint\nstep pass by disabling rules.\n',
7503
7692
  "react-scaffold": '---\nname: react-scaffold\ndescription: React 19 with Vite, TypeScript, and Tailwind CSS v4 scaffold generated manually without npm create. Use for app UIs, dashboards, auth flows, CRUD interfaces, or explicit React requests.\n---\n\n# React Scaffold\n\n## Stack Briefing\n\nReact 19 + Vite + TypeScript, written manually (never `npm create`), with\nTailwind v4 as the default styling layer. Use it for app-like UIs where\ncomponent state and interaction justify a framework. Output must stay\nstatic-hostable: `base: \'/\'`, history routing (clean URLs, no `#`), build to\n`dist/`. Do not over-split into dozens of trivial components \u2014 keep the tree\npragmatic and typed.\n\n## When To Use\n\nUse this skill when `selected_stack` is React. React is appropriate for app-like interfaces, dashboard/admin UIs, authenticated user flows, complex client state, CRUD screens, and explicit React requests.\n\nDo not use React just because the workspace is empty. Static marketing/content sites should usually use `vanilla-scaffold`.\n\n## When Not To Use\n\n- Static marketing/content/portfolio sites \u2192 `vanilla-scaffold`.\n- Content-heavy multi-page sites better served by Astro \u2192 `astro-scaffold`.\n- Anything requiring SSR/server rendering \u2014 output here is static export only.\n\n## HARD STOP: Never Re-Scaffold An Existing App\n\nBefore writing ANY scaffold file, check the workspace. If `package.json` and\n`src/` already exist, this project already has an app \u2014 scaffolding is DONE\nand this skill must not overwrite it. Read the existing `package.json`,\n`src/App.tsx` and the `src/` tree first, then build ON TOP of the existing\nfiles: keep the entry points, routing, and dependency choices already in\nplace. Overwriting `package.json`/`App.tsx`/`main.tsx` on an existing project\ndestroys the user\'s app (this happened in production). If the existing code\nseems inconsistent with the request, ask the user \u2014 never replace silently.\n\n**Rewriting a file must never drop an `import "./x.css"` it carried.** Nothing\ncatches it: tsc/eslint do not read CSS, an unimported stylesheet is legal so\nthe bundler is silent, and the page renders unstyled (prod 2026-08-01: 38 of\n85 class names left the bundle, all gates green). Prefer `edit` over a full\n`write`; if you rewrite, carry the original import block over.\n\n## Required File Shape\n\nThis is the shape a React project has here. Depending on how the project\nstarted, these files may already exist in the workspace \u2014 check first:\n\n```text\nindex.html entry: favicon link, #root, module script\npackage.json build = `vite build`; `npm run verify` runs the full gate\nvite.config.ts base: \'/\', tailwindcss() + react()\ntsconfig.json ONE flat config, include: ["src"]\neslint.config.mjs the only ESLint config in the project\npublic/\n favicon.svg keep it \u2014 without a favicon every page logs a 404\nsrc/\n App.tsx placeholder shell: header/nav, hero, cards, footer\n main.tsx entry, imports ./index.css\n index.css THE stylesheet \u2014 Tailwind by default, see Styling\n vite-env.d.ts\n.gitignore\n```\n\n**Empty workspace** \u2014 create exactly this shape.\n\n**Files already present** \u2014 **edit them, do not recreate them.** Rewrite the\ncopy, restyle the `@theme` tokens, add components and routes, but keep the\nshape. Each part of it prevents a defect that has shipped to real users.\n\n**A project that came from an older `create-vite` starter** carries config\nthat silently disables checks the platform believes it ran. Repair config\nonly \u2014 never a full-file `write` of `App.tsx`, `main.tsx` or `package.json`,\nand never as a pretext to re-scaffold:\n\n| found | do |\n| --- | --- |\n| `tsconfig.json` with `"files": []` + `references` | always replace with the one flat config below and delete `tsconfig.app.json`/`tsconfig.node.json` \u2014 otherwise nothing is type-checked at all |\n| `eslint.config.js` | leave it alone if it is the only config. If you need the rule set below, move its contents into `eslint.config.mjs` and **delete the `.js`** \u2014 never let both exist |\n| `src/App.css` | leave a working import alone. If you rewrite `App.tsx`, carry `import \'./App.css\'` over, or fold the rules into `index.css` in the same edit \u2014 never drop it silently |\n\nSay in your summary which of these you changed and why.\n\n### One Stylesheet, Not Two\n\n`src/index.css` is the only stylesheet. Do **not** add `src/App.css`.\n\nA second stylesheet has to be imported from a component, and that import is\nthe single most fragile line in the project: one full-file `write` of\n`App.tsx` drops it, and nothing notices (prod `0njsblk0lye2vsx`, 2026-08-01 \u2014\nthe layout stylesheet left the bundle, every gate stayed green). With one\nstylesheet imported once from `main.tsx`, the failure has nowhere to happen.\n\nPut component styles wherever the chosen approach puts them \u2014 Tailwind\nclasses by default \u2014 and shared values in `index.css`.\n\n## package.json\n\n```json\n{\n "name": "project-name",\n "private": true,\n "version": "0.1.0",\n "type": "module",\n "scripts": {\n "dev": "vite",\n "build": "vite build",\n "preview": "vite preview",\n "verify": "tsc --noEmit && eslint . --max-warnings 0 && vite build"\n },\n "dependencies": {\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/vite": "^4.0.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "@vitejs/plugin-react": "^4.3.4",\n "eslint": "^9.0.0",\n "eslint-plugin-react-hooks": "^6.0.0",\n "tailwindcss": "^4.0.0",\n "typescript": "^5.0.0",\n "typescript-eslint": "^8.0.0",\n "vite": "^6.0.0"\n }\n}\n```\n\n`build` is `vite build` alone \u2014 no `tsc` in front of it. The platform runs the\ntype check itself before the build, and a `tsc` inside the build script also\nblocks the one-time build that runs when a starter is imported. `verify` is\nthe gate in one command; run it, not just `npm run build`.\n\n## vite.config.ts\n\n```ts\nimport { defineConfig } from \'vite\'\nimport react from \'@vitejs/plugin-react\'\nimport tailwindcss from \'@tailwindcss/vite\'\n\nexport default defineConfig({\n base: \'/\',\n plugins: [tailwindcss(), react()],\n})\n```\n\n## Entry Files\n\n`index.html`:\n\n```html\n<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <meta name="description" content="Replace this with a one-sentence description of the app." />\n <link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n <title>Replace this title</title>\n </head>\n <body>\n <div id="root"></div>\n <script type="module" src="./src/main.tsx"></script>\n </body>\n</html>\n```\n\nKeep the favicon link and keep `public/favicon.svg`. Without them the browser\nrequests `/favicon.ico` on every page load and the render check records a\nfailed request on an otherwise healthy project.\n\n`src/main.tsx`:\n\n```tsx\nimport React from \'react\'\nimport ReactDOM from \'react-dom/client\'\nimport App from \'./App\'\nimport \'./index.css\'\n\nReactDOM.createRoot(document.getElementById(\'root\')!).render(\n <React.StrictMode>\n <App />\n </React.StrictMode>,\n)\n```\n\n`src/index.css` as the starter ships it \u2014 Tailwind first, then tokens, then\nthe opaque page baseline:\n\n```css\n@import "tailwindcss";\n\n/* Design tokens. Tailwind v4 turns every entry here into a utility\n (--color-brand -> bg-brand/text-brand), so restyle the project by editing\n these values rather than sprinkling hex codes through the components. */\n@theme {\n --color-brand: #4f46e5;\n --color-brand-contrast: #ffffff;\n --color-surface: #ffffff;\n --color-surface-muted: #f5f5f7;\n --color-ink: #111827;\n --color-ink-muted: #6b7280;\n --radius-card: 0.75rem;\n}\n\n:root {\n color-scheme: light;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n --app-bg: #ffffff;\n --app-fg: #111827;\n}\n\n/* An opaque background on html, body is mandatory \u2014 the page is never\n transparent. Keep this rule when you restyle. */\nhtml, body {\n margin: 0;\n min-height: 100%;\n min-width: 320px;\n background: var(--app-bg);\n color: var(--app-fg);\n}\n\n/* Keyboard focus baseline. Components may override it, but never remove it:\n the platform appends a generic fallback to any project whose CSS contains\n no `:focus` rule at all, and a real one belongs here. */\n:focus-visible {\n outline: 2px solid var(--color-brand, currentColor);\n outline-offset: 2px;\n}\n```\n\nEvery `@theme` entry becomes a utility (`--color-brand` \u2192 `bg-brand`), so\nrestyle by editing tokens instead of scattering hex codes through components.\nIf the user chose a non-Tailwind approach, keep `:root` and the `html, body`\nrule and replace the rest \u2014 the opaque background is not optional.\n\n`src/vite-env.d.ts`:\n\n```ts\n/// <reference types="vite/client" />\n```\n\n## TypeScript Config\n\nOne flat `tsconfig.json` with `include`, no project references and no\n`tsconfig.app.json`/`tsconfig.node.json`.\n\nThis is not a style preference. The platform gate runs `tsc --noEmit` against\nthe root `tsconfig.json`. A `create-vite`-style root \u2014 `"files": []` plus\n`references` \u2014 makes that command compile **zero files**: it exits 0 on a\nproject full of type errors, and the gate reports a pass it never performed.\nMeasured 2026-08-04: a deliberate type error passes the referenced shape and\nfails the flat one. `vite.config.ts` is deliberately not type-checked.\n\n`tsconfig.json`:\n\n```json\n{\n "compilerOptions": {\n "target": "ES2020",\n "useDefineForClassFields": true,\n "lib": ["ES2020", "DOM", "DOM.Iterable"],\n "module": "ESNext",\n "skipLibCheck": true,\n "moduleResolution": "bundler",\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "isolatedModules": true,\n "noEmit": true,\n "jsx": "react-jsx",\n "strict": true\n },\n "include": ["src"]\n}\n```\n\n## ESLint Config\n\nEvery scaffold ships **exactly one** ESLint config, `eslint.config.mjs` (flat\nconfig), so the platform lint gate and the live ESLint diagnostics work from\nthe first turn. Keep it exactly this minimal \u2014 correctness rules only, no\nstylistic rules, nothing that fights Prettier:\n\n`eslint.config.mjs`:\n\n```js\nimport tseslint from \'typescript-eslint\'\nimport reactHooks from \'eslint-plugin-react-hooks\'\n\nexport default tseslint.config(\n { ignores: [\'dist\'] },\n {\n files: [\'**/*.{ts,tsx}\'],\n extends: [tseslint.configs.base],\n plugins: { \'react-hooks\': reactHooks },\n rules: {\n \'react-hooks/rules-of-hooks\': \'error\',\n \'react-hooks/exhaustive-deps\': \'error\',\n \'react-hooks/set-state-in-effect\': \'error\',\n \'react-hooks/no-deriving-state-in-effects\': \'error\',\n \'@typescript-eslint/no-unused-vars\': [\n \'error\',\n { argsIgnorePattern: \'^_\', varsIgnorePattern: \'^_\' },\n ],\n },\n },\n)\n```\n\n**Never add a second config file.** ESLint resolves `eslint.config.js` before\n`eslint.config.mjs` and uses only the first one it finds, silently. A stray\n`.js` next to the `.mjs` therefore disables every rule above without a\nwarning \u2014 measured on ESLint 9: a violation that fails with the `.mjs` alone\npasses when both files exist. So in an existing project, either keep its\n`eslint.config.js` as the single config, or migrate it into\n`eslint.config.mjs` and delete the `.js` \u2014 never leave both behind.\n\nThe platform runs `npx eslint . --max-warnings 0` before every build, so any\nviolation blocks the build. Do not add rules, do not remove rules, and never\n"fix" a violation with `eslint-disable` or config edits \u2014 fix the code (see\nthe hooks patterns below).\n\n## Allowed Complexity\n\n- Add `src/components/`, `src/pages/`, `src/lib/`, `src/hooks/` only when a\n feature needs them \u2014 do not scaffold empty folders.\n- Introduce state libraries or data-fetching only when real shared state or\n server data exists; local `useState`/`useReducer` covers most cases.\n- Keep components typed (props/return types); avoid `any`.\n\n## CSS And Styling Expectations\n\nThe starter ships Tailwind v4 via `@tailwindcss/vite`, and that is the\ndefault: keep it and tokenize theme values in `@theme`.\n\n**The user\'s request wins.** If they ask for a different styling approach,\nfollow them and adjust the setup in the same turn:\n\n- **shadcn/ui** \u2014 is built ON Tailwind. Keep Tailwind, add the components.\n Never remove Tailwind to "make room" for it.\n- **A CSS-in-JS library** (Chakra, MUI, styled-components, Emotion) \u2014 add it\n and build with it. Leaving Tailwind installed is harmless (v4 emits only\n the classes you use), so remove it only if the user asks.\n- **Plain CSS / CSS Modules** \u2014 drop `@tailwindcss/vite` and `tailwindcss`\n from `package.json`, remove the plugin from `vite.config.ts`, and replace\n `@import "tailwindcss"` in `index.css` with your own base styles. Keep the\n `:root` tokens and the opaque `html, body` rule.\n\nWhatever the approach, these hold:\n\n- **One stylesheet** \u2014 the rule below is about losing an import, not about\n Tailwind. It applies to plain CSS just as much.\n- Mobile-first, responsive layouts; visible focus states; WCAG AA contrast.\n- The loaded `style-*` skill governs the visual language \u2014 apply its recipe.\n It is written to be independent of the styling technology.\n\n## Routing Rules\n\nIf adding React Router, use `BrowserRouter` (history mode) with `base: \'/\'` in\n`vite.config.ts`. The platform serves at the domain root and falls unknown\ndeep-links back to `index.html`, so routes resolve on hard refresh with clean\nURLs (no `#`). Never use `HashRouter`. Add `react-router-dom` only when routing\nis actually needed.\n\n## Data And Backend Rules\n\n- No database/server calls unless a backend is configured (respect the build\n agent\'s STOP gate). Do not invent mock backends or fake data arrays.\n- Keep secrets out of client code; only public keys via `.env.local`.\n\n## Asset Path Rules\n\n- Set `base: \'/\'` in `vite.config.ts`. The platform serves the project at the\n domain root, and history-mode routes need root-absolute assets.\n- Reference the project\'s own assets/routes with root-absolute paths (`/assets/...`);\n no `<base>` tag, no hardcoded platform URLs.\n\n## Verification\n\nRun install, then the full platform verification contract (see the build\nagent\'s "Build And Preview Rules"), not just `npm run build`:\n\n1. `npx tsc --noEmit`\n2. `npx eslint . --max-warnings 0` \u2014 a project scaffolded from this skill\n ships `eslint.config.mjs`, so this check ALWAYS applies and decides\n acceptance.\n3. `npm run build`\n4. `dist/index.html` exists\n\n`npm run verify` runs 1\u20133 in order. Never make step 2 pass by disabling rules\nor adding `eslint-disable`.\n\n## React Hooks Rules That Fail The Gate\n\nThe scaffold\'s `eslint.config.mjs` enables `eslint-plugin-react-hooks` rules\nthat reject patterns which compile and build fine. These are the ones that\nactually show up \u2014 avoid them while writing, not after:\n\n- **Never sync props into state inside an effect**\n (`useEffect(() => setForm(props.item), [props.item])` \u2192 `set-state-in-effect`).\n Derive the value during render, lift the state up, or remount the subtree\n with a `key` when the edited record changes \u2014 that is usually the intended\n "reset the form" semantic anyway.\n- **Do not open dialogs/editors from an effect that watches the URL.** Derive\n the open state from the route during render, or set it in the event handler\n that triggered the navigation.\n- **Do not bootstrap an external store from a render-time side effect.** Read\n it with `useSyncExternalStore`, or initialize it in the store module itself.\n- **Async loads:** only update state after an await when the effect has not\n been cleaned up (cancellation flag or `AbortController`).\n- **No impure render calls** \u2014 no `Math.random()`, `Date.now()`, or mutation\n during render. Move them into a lazy initializer or an event handler.\n- **Fast Refresh:** a module that exports a component must not also export\n unrelated non-component values (`react-refresh/only-export-components`).\n\nA `setTimeout`/microtask wrapper that only hides the violation from the linter\nis not a fix \u2014 it hides the same bug behind a race.\n\n## Common Failure Modes\n\n- Recreating the starter\'s files instead of editing them.\n- Adding `src/App.css` (or any second stylesheet) \u2014 see "One Stylesheet".\n- Adding `eslint.config.js` beside `eslint.config.mjs`, which silently\n disables the rule set.\n- Restoring the `create-vite` tsconfig trio, which makes the type check inert.\n- Putting `tsc` back into the `build` script.\n- Deleting `public/favicon.svg` or its `<link rel="icon">`.\n- Using `HashRouter` \u2192 ugly `#` URLs; use `BrowserRouter` (history mode).\n- Keeping relative `base: \'./\'` with history routing \u2192 assets 404 on a deep-link\n hard refresh; use `base: \'/\'`.\n- Leaving the starter\'s placeholder copy ("Replace this headline", "Brand",\n "First point") in the shipped page.\n- Over-splitting into trivial components; untyped `any` props.\n- Treating pre-install JSX type errors as source bugs (install deps first).\n',
7504
7693
  "vue-scaffold": '---\nname: vue-scaffold\ndescription: Vue 3.5 with Vite, TypeScript, and Tailwind CSS v4 scaffold generated manually without npm create. Use for explicit Vue requests or existing Vue projects.\n---\n\n# Vue Scaffold\n\n## Stack Briefing\n\nVue 3.5 + Vite + TypeScript + Tailwind v4, written manually (never `npm create`).\nUse it for app-like Vue UIs. Output must stay static-hostable: `base: \'/\'`,\nhistory mode (clean URLs, no `#`), build to `dist/`. Use the Composition API; add Pinia or Vue Router\nonly when real shared state or routing actually exists.\n\n## When To Use\n\nUse this skill when `selected_stack` is Vue. Prefer Vue only when requested, when the existing project uses Vue, or when the user clearly wants a Vue-style app.\n\n## When Not To Use\n\n- Static marketing/content/portfolio sites \u2192 `vanilla-scaffold`.\n- Content-heavy multi-page sites \u2192 `astro-scaffold`.\n- Anything needing SSR \u2014 output here is static only.\n\n## HARD STOP: Never Re-Scaffold An Existing App\n\nBefore writing ANY scaffold file, check the workspace. If `package.json` (or\nan existing `index.html` app shell for vanilla projects) and source files\nalready exist, this project already has an app \u2014 scaffolding is DONE and this\nskill must not overwrite it. Read the existing entry points and source tree\nfirst, then build ON TOP of the existing files: keep the entry points,\nrouting, and dependency choices already in place. Overwriting the scaffold\nfiles on an existing project destroys the user\'s app (this happened in\nproduction). If the existing code seems inconsistent with the request, ask\nthe user \u2014 never replace silently.\n\n## Required File Shape\n\nThis is the shape a Vue project has here. Depending on how the project\nstarted, these files may already exist in the workspace \u2014 check first:\n\n```text\nindex.html entry: favicon link, #app, module script\npackage.json build = `vite build`; `npm run verify` runs the full gate\nvite.config.ts base: \'/\', tailwindcss() + vue()\ntsconfig.json ONE flat config, no references\neslint.config.mjs the only ESLint config in the project\npublic/\n favicon.svg keep it \u2014 without a favicon every page logs a 404\nsrc/\n App.vue placeholder shell: header/nav, hero, cards, footer\n main.ts entry, imports ./style.css\n style.css THE stylesheet \u2014 Tailwind by default\n env.d.ts vite/client types (inside src/, so the include glob covers it)\n shims-vue.d.ts lets plain `tsc` resolve *.vue \u2014 see below, load-bearing\n.gitignore\n```\n\n**Empty workspace** \u2014 create exactly this shape.\n\n**Files already present** \u2014 **edit them, do not recreate them.** Rewrite the\ncopy, restyle the `@theme` tokens, add components and routes, but keep the\nshape.\n\n**A project from an older `create-vue` starter** ships config that silently\ndisables checks the platform believes it ran. Repair config only \u2014 never a\nfull-file `write` of `App.vue`, `main.ts` or `package.json`:\n\n| found | do |\n| --- | --- |\n| `tsconfig.json` with `"files": []` + `references` | replace with the flat config below, delete `tsconfig.app.json`/`tsconfig.node.json`, and add `src/shims-vue.d.ts` \u2014 the trio makes the type check inert, but removing it without the shim makes it fail |\n| no ESLint config at all | add `eslint.config.mjs` below \u2014 without it the lint gate is inert and never sees a template bug |\n| `src/router/index.ts` with `routes: []`, an unused store | delete them, or give them real routes/state |\n\nCreate `src/components/`, `src/views/`, `src/router/`, or `src/stores/` only when needed.\n\n## package.json\n\n```json\n{\n "name": "project-name",\n "private": true,\n "version": "0.1.0",\n "type": "module",\n "scripts": {\n "dev": "vite",\n "build": "vite build",\n "preview": "vite preview",\n "verify": "vue-tsc --noEmit && eslint . --max-warnings 0 && vite build"\n },\n "dependencies": {\n "vue": "^3.5.0"\n },\n "devDependencies": {\n "@tailwindcss/vite": "^4.0.0",\n "@vitejs/plugin-vue": "^6.0.0",\n "eslint": "^9.0.0",\n "eslint-plugin-vue": "^10.0.0",\n "tailwindcss": "^4.0.0",\n "typescript": "^5.0.0",\n "typescript-eslint": "^8.0.0",\n "vite": "^6.0.0",\n "vue-tsc": "^2.2.0"\n }\n}\n```\n\n## vite.config.ts\n\n```ts\nimport { defineConfig } from \'vite\'\nimport vue from \'@vitejs/plugin-vue\'\nimport tailwindcss from \'@tailwindcss/vite\'\n\nexport default defineConfig({\n base: \'/\',\n plugins: [tailwindcss(), vue()],\n})\n```\n\n## Entry Files\n\n`index.html`:\n\n```html\n<!doctype html>\n<html lang="en">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <meta name="description" content="Replace this with a one-sentence description of the app." />\n <link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n <title>Replace this title</title>\n </head>\n <body>\n <div id="app"></div>\n <script type="module" src="./src/main.ts"></script>\n </body>\n</html>\n```\n\n`src/main.ts`:\n\n```ts\nimport { createApp } from \'vue\'\nimport App from \'./App.vue\'\nimport \'./style.css\'\n\ncreateApp(App).mount(\'#app\')\n```\n\n`src/style.css`:\n\n```css\n@import "tailwindcss";\n\n/* Design tokens. Tailwind v4 turns every entry here into a utility\n (--color-brand -> bg-brand/text-brand), so restyle the project by editing\n these values rather than sprinkling hex codes through the components. */\n@theme {\n --color-brand: #41b883;\n --color-brand-contrast: #ffffff;\n --color-surface: #ffffff;\n --color-surface-muted: #f5f5f7;\n --color-ink: #111827;\n --color-ink-muted: #6b7280;\n --radius-card: 0.75rem;\n}\n\n:root {\n color-scheme: light;\n font-family: Inter, ui-sans-serif, system-ui, sans-serif;\n --app-bg: #ffffff;\n --app-fg: #111827;\n}\n\n/* An opaque background on html, body is mandatory \u2014 the page is never\n transparent. Keep this rule when you restyle. */\nhtml, body {\n margin: 0;\n min-height: 100%;\n min-width: 320px;\n background: var(--app-bg);\n color: var(--app-fg);\n}\n\n/* Keyboard focus baseline. Components may override it, but never remove it:\n the platform appends a generic fallback to any project whose CSS contains\n no `:focus` rule at all, and a real one belongs here. */\n:focus-visible {\n outline: 2px solid var(--color-brand, currentColor);\n outline-offset: 2px;\n}\n```\n\nTailwind is the **default**, not a mandate \u2014 the same rule as elsewhere: if\nthe user asks for a different styling approach, follow them and adjust the\nsetup in the same turn. Vue SFC `<style scoped>` blocks are idiomatic and fine\nalongside it; the thing to avoid is a second global stylesheet imported from a\ncomponent, because one full-file rewrite drops that import silently.\n\n## TypeScript Config\n\nOne flat `tsconfig.json` with `include`, no project references and no\n`tsconfig.app.json`/`tsconfig.node.json`.\n\nThis is not a style preference. The platform gate runs `tsc --noEmit` against\nthe root `tsconfig.json`. A `create-vue`-style root \u2014 `"files": []` plus\n`references` \u2014 makes that command compile **zero files**: it exits 0 on a\nproject full of type errors and the gate reports a pass it never performed.\n\n`vite.config.ts` is deliberately not type-checked.\n\n### `src/shims-vue.d.ts` Is Load-Bearing\n\nThe gate runs `tsc`, **never `vue-tsc`** \u2014 and plain `tsc` cannot resolve an\nimport of a `.vue` file. Without the shim the very first line of `main.ts`\nfails the gate on a phantom error (measured 2026-08-04):\n\n```text\nsrc/main.ts(2,17): error TS2307: Cannot find module \'./App.vue\'\n```\n\nSo the flat tsconfig and the shim are one change: applying either alone is\nworse than the broken state it replaces. Ship both.\n\n`src/shims-vue.d.ts`:\n\n```ts\n/* Lets plain `tsc` resolve `*.vue` imports.\n *\n * Load-bearing: the platform\'s prebuild gate runs `tsc --noEmit`, never\n * `vue-tsc`. Without this shim it stops at\n * src/main.ts: error TS2307: Cannot find module \'./App.vue\'\n * and every build fails on a phantom error.\n *\n * The shim types an SFC as a generic component, so `tsc` checks all `.ts`\n * files properly but not the internals of a `.vue` file. `npm run verify`\n * runs `vue-tsc --noEmit`, which does check those \u2014 use it before finishing.\n */\ndeclare module \'*.vue\' {\n import type { DefineComponent } from \'vue\'\n const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>\n export default component\n}\n```\n\nWhat this buys and what it does not \u2014 all three measured:\n\n| | `tsc` (the gate) | `vue-tsc` (`npm run verify`) |\n| --- | --- | --- |\n| type error in a `.ts` file | caught | caught |\n| type error inside a `.vue` SFC | **missed** \u2014 the shim types it generically | caught |\n\nThat is why `verify` runs `vue-tsc` and why passing the gate is not the same\nas being done.\n\n`tsconfig.json`:\n\n```json\n{\n "compilerOptions": {\n "target": "ES2020",\n "useDefineForClassFields": true,\n "module": "ESNext",\n "lib": ["ES2020", "DOM", "DOM.Iterable"],\n "skipLibCheck": true,\n "moduleResolution": "bundler",\n "allowImportingTsExtensions": true,\n "resolveJsonModule": true,\n "isolatedModules": true,\n "noEmit": true,\n "jsx": "preserve",\n "strict": true\n },\n "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]\n}\n```\n\n## Routing Rules\n\nIf adding Vue Router, use `createWebHistory()` (history mode) with `base: \'/\'` in\n`vite.config.ts`. The platform serves at the domain root and falls unknown\ndeep-links back to `index.html`, so routes resolve on hard refresh with clean\nURLs (no `#`). Never use `createWebHashHistory()`. Add `vue-router` only when\nrouting is needed.\n\n## Data And Backend Rules\n\n- No database/server calls unless a backend is configured (respect the build\n agent\'s STOP gate). Do not invent mock backends or fake data arrays.\n- Keep secrets out of client code; only public keys via `.env.local`.\n\n## Asset Path Rules\n\n- Set `base: \'/\'`. The platform serves the project at the domain root, and\n history-mode routes need root-absolute assets. Reference the project\'s own\n assets/routes with root-absolute paths. No `<base>` tag, no hardcoded platform URLs.\n\n## Common Failure Modes\n\n- Using `createWebHashHistory()` \u2192 ugly `#` URLs; use `createWebHistory()`.\n- Keeping relative `base: \'./\'` with history routing \u2192 assets 404 on a deep-link\n hard refresh; use `base: \'/\'`.\n- Leaving starter boilerplate in `App.vue`.\n- Adding Pinia/Router with no real need.\n\n## ESLint Config\n\nEvery scaffold ships `eslint.config.mjs` (flat config) so the platform lint\ngate and the live ESLint diagnostics work from the very first turn. Verified\nempirically 2026-08-02: `flat/essential` catches real template bugs\n(`vue/require-v-for-key` etc.); `vue/multi-word-component-names` is switched\nOFF because with `--max-warnings 0` it would block every `Hero.vue`-style\nsingle-word component \u2014 a naming convention, not a correctness rule:\n\n`eslint.config.mjs`:\n\n```js\nimport tseslint from \'typescript-eslint\'\nimport vue from \'eslint-plugin-vue\'\n\nexport default tseslint.config(\n { ignores: [\'dist\'] },\n {\n files: [\'**/*.ts\'],\n extends: [tseslint.configs.base],\n rules: {\n \'@typescript-eslint/no-unused-vars\': [\n \'error\',\n { argsIgnorePattern: \'^_\', varsIgnorePattern: \'^_\' },\n ],\n },\n },\n ...vue.configs[\'flat/essential\'],\n {\n files: [\'**/*.vue\'],\n languageOptions: { parserOptions: { parser: tseslint.parser } },\n rules: {\n \'vue/multi-word-component-names\': \'off\',\n },\n },\n)\n```\n\nThe platform runs `npx eslint . --max-warnings 0` before every build, so any\nviolation blocks the build. Do not add or remove rules, and never "fix" a\nviolation with `eslint-disable` or config edits \u2014 fix the code.\n\n## Verification\n\nRun install, then the full platform verification contract (see the build\nagent\'s "Build And Preview Rules"): typecheck, `npx eslint . --max-warnings 0`\n(the scaffold ships `eslint.config.mjs`, so this check ALWAYS applies and\ndecides acceptance), `npm run build`, and finally verify that\n`dist/index.html` exists. `npm run build` alone is not what the platform gate\nchecks. Never make the lint step pass by disabling rules.\n',
@@ -7559,7 +7748,7 @@ var SCAFFOLD_BY_FRAMEWORK = {
7559
7748
  };
7560
7749
  function hasVueDependency(rootDir) {
7561
7750
  try {
7562
- const pkg = JSON.parse((0, import_node_fs13.readFileSync)((0, import_node_path15.join)(rootDir, "package.json"), "utf8"));
7751
+ const pkg = JSON.parse((0, import_node_fs14.readFileSync)((0, import_node_path16.join)(rootDir, "package.json"), "utf8"));
7563
7752
  return Boolean({ ...pkg.dependencies, ...pkg.devDependencies }["vue"]);
7564
7753
  } catch {
7565
7754
  return false;
@@ -7589,7 +7778,7 @@ function renderSkillsInstruction(names) {
7589
7778
  return "# Platform deployment skills (mandatory)\n\nThese are the same skills the cloud build agent loads for this stack.\nFollow them when scaffolding or editing \u2014 they are not optional hints.\n\n" + sections.join("\n\n---\n\n") + "\n";
7590
7779
  }
7591
7780
  function skillsInstructionFor(framework, rootDir) {
7592
- if (!(0, import_node_fs13.existsSync)(rootDir)) return null;
7781
+ if (!(0, import_node_fs14.existsSync)(rootDir)) return null;
7593
7782
  return renderSkillsInstruction(selectSkills(framework, rootDir));
7594
7783
  }
7595
7784
 
@@ -7737,15 +7926,15 @@ function decideLocalProviders(policy) {
7737
7926
  }
7738
7927
 
7739
7928
  // src/lib/localProviders.ts
7740
- var import_node_fs14 = require("fs");
7741
- var import_node_path16 = require("path");
7929
+ var import_node_fs15 = require("fs");
7930
+ var import_node_path17 = require("path");
7742
7931
  var LOCAL_PROVIDERS_FILE = "local-providers.json";
7743
7932
  function localProvidersPath() {
7744
- return (0, import_node_path16.join)(configDir(), LOCAL_PROVIDERS_FILE);
7933
+ return (0, import_node_path17.join)(configDir(), LOCAL_PROVIDERS_FILE);
7745
7934
  }
7746
7935
  function readLocalProviders() {
7747
7936
  try {
7748
- const raw = JSON.parse((0, import_node_fs14.readFileSync)(localProvidersPath(), "utf8"));
7937
+ const raw = JSON.parse((0, import_node_fs15.readFileSync)(localProvidersPath(), "utf8"));
7749
7938
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
7750
7939
  const map = raw.providers && typeof raw.providers === "object" ? raw.providers : raw;
7751
7940
  const usable = {};
@@ -7780,8 +7969,8 @@ function resolveLocalProviders(policy) {
7780
7969
  }
7781
7970
  function writeLocalProvidersExample() {
7782
7971
  const path = localProvidersPath();
7783
- if ((0, import_node_fs14.existsSync)(path)) return path;
7784
- (0, import_node_fs14.mkdirSync)(configDir(), { recursive: true });
7972
+ if ((0, import_node_fs15.existsSync)(path)) return path;
7973
+ (0, import_node_fs15.mkdirSync)(configDir(), { recursive: true });
7785
7974
  const example = {
7786
7975
  _comment: 'Providers with your own API key, running locally. Requests go directly to the provider \u2014 no Agentful credits, no platform region guarantee. Remove the leading underscore from "_anthropic" and fill in your key to enable it.',
7787
7976
  _anthropic: {
@@ -7791,17 +7980,17 @@ function writeLocalProvidersExample() {
7791
7980
  models: { "claude-sonnet-4-5": { name: "Claude Sonnet 4.5 (my key)" } }
7792
7981
  }
7793
7982
  };
7794
- (0, import_node_fs14.writeFileSync)(path, JSON.stringify(example, null, 2) + "\n", "utf8");
7983
+ (0, import_node_fs15.writeFileSync)(path, JSON.stringify(example, null, 2) + "\n", "utf8");
7795
7984
  try {
7796
- (0, import_node_fs14.chmodSync)(path, 384);
7985
+ (0, import_node_fs15.chmodSync)(path, 384);
7797
7986
  } catch {
7798
7987
  }
7799
7988
  return path;
7800
7989
  }
7801
7990
 
7802
7991
  // src/lib/sessions.ts
7803
- var import_node_fs15 = require("fs");
7804
- var import_node_path17 = require("path");
7992
+ var import_node_fs16 = require("fs");
7993
+ var import_node_path18 = require("path");
7805
7994
  var import_node_child_process4 = require("child_process");
7806
7995
  function parseSessionModel(raw) {
7807
7996
  if (!raw) return null;
@@ -7818,8 +8007,8 @@ function parseSessionModel(raw) {
7818
8007
  }
7819
8008
  }
7820
8009
  function modelForSession(sessionID) {
7821
- const dbPath = (0, import_node_path17.join)(engineXdg().dataHome, "opencode", "opencode.db");
7822
- if (!(0, import_node_fs15.existsSync)(dbPath)) return null;
8010
+ const dbPath = (0, import_node_path18.join)(engineXdg().dataHome, "opencode", "opencode.db");
8011
+ if (!(0, import_node_fs16.existsSync)(dbPath)) return null;
7823
8012
  const escaped = sessionID.replace(/'/g, "''");
7824
8013
  const res = (0, import_node_child_process4.spawnSync)(
7825
8014
  "sqlite3",
@@ -7840,8 +8029,8 @@ function modelForSession(sessionID) {
7840
8029
  }
7841
8030
  }
7842
8031
  function lastSessionForDirectory(cwd = process.cwd()) {
7843
- const dbPath = (0, import_node_path17.join)(engineXdg().dataHome, "opencode", "opencode.db");
7844
- if (!(0, import_node_fs15.existsSync)(dbPath)) return null;
8032
+ const dbPath = (0, import_node_path18.join)(engineXdg().dataHome, "opencode", "opencode.db");
8033
+ if (!(0, import_node_fs16.existsSync)(dbPath)) return null;
7845
8034
  const escaped = cwd.replace(/'/g, "''");
7846
8035
  const res = (0, import_node_child_process4.spawnSync)(
7847
8036
  "sqlite3",
@@ -7867,9 +8056,9 @@ function lastSessionForDirectory(cwd = process.cwd()) {
7867
8056
  }
7868
8057
 
7869
8058
  // src/lib/modelPin.ts
7870
- var import_node_fs16 = require("fs");
7871
- var import_node_path18 = require("path");
7872
- var PROJECT_MODEL_PIN = (0, import_node_path18.join)(".agentful", "model.json");
8059
+ var import_node_fs17 = require("fs");
8060
+ var import_node_path19 = require("path");
8061
+ var PROJECT_MODEL_PIN = (0, import_node_path19.join)(".agentful", "model.json");
7873
8062
  function isCanonicalModel(value) {
7874
8063
  const slash = value.indexOf("/");
7875
8064
  return slash > 0 && slash < value.length - 1 && !/\s/.test(value);
@@ -7889,10 +8078,10 @@ function assertModelPin(pin, source) {
7889
8078
  return pin;
7890
8079
  }
7891
8080
  function readProjectModelPin(cwd = process.cwd()) {
7892
- const path = (0, import_node_path18.join)(cwd, PROJECT_MODEL_PIN);
8081
+ const path = (0, import_node_path19.join)(cwd, PROJECT_MODEL_PIN);
7893
8082
  let raw;
7894
8083
  try {
7895
- raw = (0, import_node_fs16.readFileSync)(path, "utf8");
8084
+ raw = (0, import_node_fs17.readFileSync)(path, "utf8");
7896
8085
  } catch (error) {
7897
8086
  const code = error.code;
7898
8087
  if (code === "ENOENT") return null;
@@ -7919,7 +8108,7 @@ function readProjectModelPin(cwd = process.cwd()) {
7919
8108
  function readEngineVariants(xdg) {
7920
8109
  try {
7921
8110
  const parsed = JSON.parse(
7922
- (0, import_node_fs16.readFileSync)((0, import_node_path18.join)(xdg.stateHome, "opencode", "model.json"), "utf8")
8111
+ (0, import_node_fs17.readFileSync)((0, import_node_path19.join)(xdg.stateHome, "opencode", "model.json"), "utf8")
7923
8112
  );
7924
8113
  if (!parsed.variant || typeof parsed.variant !== "object" || Array.isArray(parsed.variant)) return {};
7925
8114
  return Object.fromEntries(
@@ -7931,17 +8120,17 @@ function readEngineVariants(xdg) {
7931
8120
  }
7932
8121
  function writeEngineVariant(xdg, model, variant) {
7933
8122
  assertModelPin({ model, variant }, "Selected");
7934
- const path = (0, import_node_path18.join)(xdg.stateHome, "opencode", "model.json");
8123
+ const path = (0, import_node_path19.join)(xdg.stateHome, "opencode", "model.json");
7935
8124
  let state = {};
7936
8125
  try {
7937
- const parsed = JSON.parse((0, import_node_fs16.readFileSync)(path, "utf8"));
8126
+ const parsed = JSON.parse((0, import_node_fs17.readFileSync)(path, "utf8"));
7938
8127
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
7939
8128
  } catch {
7940
8129
  }
7941
8130
  const existing = state.variant;
7942
8131
  const variants = existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {};
7943
8132
  state.variant = { ...variants, [model]: variant };
7944
- (0, import_node_fs16.writeFileSync)(path, JSON.stringify(state, null, 2) + "\n", "utf8");
8133
+ (0, import_node_fs17.writeFileSync)(path, JSON.stringify(state, null, 2) + "\n", "utf8");
7945
8134
  }
7946
8135
  function resolveModelPin(opts) {
7947
8136
  const cliPin = opts.cliModel ? assertModelPin({ model: opts.cliModel, variant: opts.cliVariant }, "CLI") : null;
@@ -7990,8 +8179,8 @@ function resolveModelPin(opts) {
7990
8179
  // src/lib/engineModels.ts
7991
8180
  var import_node_child_process5 = require("child_process");
7992
8181
  var import_node_util = require("util");
7993
- var import_node_fs17 = require("fs");
7994
- var import_node_path19 = require("path");
8182
+ var import_node_fs18 = require("fs");
8183
+ var import_node_path20 = require("path");
7995
8184
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process5.execFile);
7996
8185
  function parseEngineModelsOutput(output) {
7997
8186
  return Array.from(new Set(
@@ -8042,7 +8231,7 @@ async function assertEngineModelAvailable(binPath, xdg, model, listModels = list
8042
8231
  function readConnectedEngineProviders(xdg) {
8043
8232
  try {
8044
8233
  const parsed = JSON.parse(
8045
- (0, import_node_fs17.readFileSync)((0, import_node_path19.join)(xdg.dataHome, "opencode", "auth.json"), "utf8")
8234
+ (0, import_node_fs18.readFileSync)((0, import_node_path20.join)(xdg.dataHome, "opencode", "auth.json"), "utf8")
8046
8235
  );
8047
8236
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
8048
8237
  return Object.keys(parsed).filter((id) => id !== "agentful").sort();
@@ -8470,7 +8659,7 @@ async function tuiCommand(opts = {}) {
8470
8659
  ...process.env,
8471
8660
  // Slash commands run `agentful …` through the agent's bash tool, which
8472
8661
  // has no access to the user's shell aliases.
8473
- PATH: `${binDir}${import_node_path20.delimiter}${process.env.PATH || ""}`,
8662
+ PATH: `${binDir}${import_node_path21.delimiter}${process.env.PATH || ""}`,
8474
8663
  XDG_CONFIG_HOME: xdg.configHome,
8475
8664
  XDG_DATA_HOME: xdg.dataHome,
8476
8665
  XDG_STATE_HOME: xdg.stateHome,
@@ -8601,38 +8790,38 @@ async function shareCommand(opts) {
8601
8790
  }
8602
8791
 
8603
8792
  // src/commands/pull.ts
8604
- var import_node_fs19 = require("fs");
8605
- var import_node_path22 = require("path");
8793
+ var import_node_fs20 = require("fs");
8794
+ var import_node_path23 = require("path");
8606
8795
  init_branding();
8607
8796
  init_branding();
8608
8797
 
8609
8798
  // src/lib/pullFiles.ts
8610
- var import_node_fs18 = require("fs");
8611
- var import_node_path21 = require("path");
8799
+ var import_node_fs19 = require("fs");
8800
+ var import_node_path22 = require("path");
8612
8801
  var INLINE_BASE64_EXTENSIONS = /\.(?:png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?|woff2?|ttf|otf|eot|pdf|docx?|xlsx?|pptx?|zip|tar|gz|7z|rar|bz2|mp[34]|m4a|wav|ogg|avi|mov|mkv|flac|webm|exe|dll|so|dylib|bin|wasm)$/i;
8613
8802
  function inlineContent(value) {
8614
8803
  return typeof value === "object" && value !== null && "content" in value ? String(value.content) : String(value ?? "");
8615
8804
  }
8616
8805
  function writePulledWorkspaceEntry(root, rel, value) {
8617
- const target = (0, import_node_path21.join)(root, rel);
8618
- (0, import_node_fs18.mkdirSync)((0, import_node_path21.dirname)(target), { recursive: true });
8806
+ const target = (0, import_node_path22.join)(root, rel);
8807
+ (0, import_node_fs19.mkdirSync)((0, import_node_path22.dirname)(target), { recursive: true });
8619
8808
  const content = inlineContent(value);
8620
8809
  if (INLINE_BASE64_EXTENSIONS.test(rel)) {
8621
- (0, import_node_fs18.writeFileSync)(target, Buffer.from(content, "base64"));
8810
+ (0, import_node_fs19.writeFileSync)(target, Buffer.from(content, "base64"));
8622
8811
  return;
8623
8812
  }
8624
- (0, import_node_fs18.writeFileSync)(target, content, "utf8");
8813
+ (0, import_node_fs19.writeFileSync)(target, content, "utf8");
8625
8814
  }
8626
8815
  function writePulledBackendDeclaration(root, declaration) {
8627
- const target = (0, import_node_path21.join)(root, ".agentful", "backend.json");
8628
- (0, import_node_fs18.mkdirSync)((0, import_node_path21.dirname)(target), { recursive: true });
8816
+ const target = (0, import_node_path22.join)(root, ".agentful", "backend.json");
8817
+ (0, import_node_fs19.mkdirSync)((0, import_node_path22.dirname)(target), { recursive: true });
8629
8818
  const {
8630
8819
  source: _source,
8631
8820
  received_at: _receivedAt,
8632
8821
  last_sync: _lastSync,
8633
8822
  ...portableDeclaration
8634
8823
  } = declaration;
8635
- (0, import_node_fs18.writeFileSync)(target, `${JSON.stringify(portableDeclaration, null, 2)}
8824
+ (0, import_node_fs19.writeFileSync)(target, `${JSON.stringify(portableDeclaration, null, 2)}
8636
8825
  `, "utf8");
8637
8826
  }
8638
8827
 
@@ -8641,7 +8830,7 @@ async function pullCommand(opts) {
8641
8830
  console.log(banner());
8642
8831
  const auth = await ensureAuth();
8643
8832
  const project = requireProject();
8644
- const nonHidden = (0, import_node_fs19.readdirSync)(process.cwd()).filter((n) => n !== ".agentful" && !n.startsWith("."));
8833
+ const nonHidden = (0, import_node_fs20.readdirSync)(process.cwd()).filter((n) => n !== ".agentful" && !n.startsWith("."));
8645
8834
  if (nonHidden.length > 0 && !opts.force) {
8646
8835
  throw new ApiError(
8647
8836
  0,
@@ -8668,9 +8857,9 @@ async function pullCommand(opts) {
8668
8857
  ui.warn(`Skipped ${rel} (HTTP ${resp.status})`);
8669
8858
  continue;
8670
8859
  }
8671
- const target = (0, import_node_path22.join)(process.cwd(), rel);
8672
- (0, import_node_fs19.mkdirSync)((0, import_node_path22.dirname)(target), { recursive: true });
8673
- (0, import_node_fs19.writeFileSync)(target, Buffer.from(await resp.arrayBuffer()));
8860
+ const target = (0, import_node_path23.join)(process.cwd(), rel);
8861
+ (0, import_node_fs20.mkdirSync)((0, import_node_path23.dirname)(target), { recursive: true });
8862
+ (0, import_node_fs20.writeFileSync)(target, Buffer.from(await resp.arrayBuffer()));
8674
8863
  written++;
8675
8864
  }
8676
8865
  } else if (data.files) {