diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f4be24a..af7aa7c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -9,6 +9,29 @@ "homepage": "https://docs.base44.com", "repository": "https://github.com/base44/skills", "license": "MIT", - "keywords": ["base44", "full-stack", "sdk", "cli", "deployment", "entities", "backend-functions", "javascript", "typescript"], - "skills": ["./skills/base44-cli/", "./skills/base44-sdk/", "./skills/base44-troubleshooter/"] + "keywords": [ + "base44", + "full-stack", + "sdk", + "cli", + "deployment", + "entities", + "backend-functions", + "javascript", + "typescript" + ], + "skills": [ + "./skills/base44-cli/", + "./skills/base44-sdk/", + "./skills/base44-troubleshooter/", + "./skills/base44-platform-platform/", + "./skills/base44-platform-entities/", + "./skills/base44-platform-backend-functions/", + "./skills/base44-platform-workflows/", + "./skills/base44-platform-agents/", + "./skills/base44-platform-actors/", + "./skills/base44-platform-integrations-sdk/", + "./skills/base44-platform-connectors/", + "./skills/base44-platform-frontend/" + ] } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7be8419..1749c73 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -4,14 +4,36 @@ "version": "1.0.0-beta.1", "description": "Build and deploy Base44 full-stack apps with CLI project management and JavaScript/TypeScript SDK development skills", "author": { - "name": "base44", - "url": "https://base44.com" + "name": "base44", + "url": "https://base44.com" }, "homepage": "https://docs.base44.com", "repository": "https://github.com/base44/skills", "license": "MIT", "logo": "./assets/base44-logo.png", - "keywords": ["base44", "full-stack", "sdk", "cli", "deployment", "entities", "backend-functions", "javascript", "typescript"], - "skills": ["./skills/base44-cli/", "./skills/base44-sdk/", "./skills/base44-troubleshooter/"] - } - \ No newline at end of file + "keywords": [ + "base44", + "full-stack", + "sdk", + "cli", + "deployment", + "entities", + "backend-functions", + "javascript", + "typescript" + ], + "skills": [ + "./skills/base44-cli/", + "./skills/base44-sdk/", + "./skills/base44-troubleshooter/", + "./skills/base44-platform-platform/", + "./skills/base44-platform-entities/", + "./skills/base44-platform-backend-functions/", + "./skills/base44-platform-workflows/", + "./skills/base44-platform-agents/", + "./skills/base44-platform-actors/", + "./skills/base44-platform-integrations-sdk/", + "./skills/base44-platform-connectors/", + "./skills/base44-platform-frontend/" + ] +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6d3cd1b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# The compiled platform skills are generated from the Base44 builder's prompt tree in +# the Base44 builder prompt and synced here by Base44 (CONTRIBUTING.md, "Compiled platform skills"). +# A hand edit here is overwritten by the next sync and fails the validator's manifest check, +# so the owners review the sync PRs and route content changes to the source prompt. +/skills/base44-platform-*/ @base44/devs +/SOURCE_COMMIT @base44/devs +/scripts/validate-base44-platform.mjs @base44/devs +/.github/workflows/validate-base44-platform.yml @base44/devs +/.github/workflows/auto-merge-skill-update.yml @base44/devs diff --git a/.github/workflows/auto-merge-skill-update.yml b/.github/workflows/auto-merge-skill-update.yml new file mode 100644 index 0000000..9a30d75 --- /dev/null +++ b/.github/workflows/auto-merge-skill-update.yml @@ -0,0 +1,45 @@ +name: Auto-merge compiled skill updates + +# A sync PR from the Base44 skills compiler (label `auto-skill-update`, opened by the +# base44-github-actions app, the same bot sync-cli-skill.yml / sync-sdk-skill.yml trust) that +# touches only the compiled `skills/base44-platform-*` tree and the SOURCE_COMMIT pin is merged +# automatically once `Validate base44-platform skills` passes. Anything else with the label (the +# CLI / SDK sync PRs, a hand-made PR) is left for a human. +# A sync PR labelled `human-merge` was published on a person's approval after an IP review +# finding: it is left for a human too, even when a later push updates it. +# +# Requires: GitHub auto-merge enabled on this repository and the validator as a required check +# on main. See CONTRIBUTING.md, "Compiled platform skills". + +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +jobs: + enable-auto-merge: + if: contains(github.event.pull_request.labels.*.name, 'auto-skill-update') && !contains(github.event.pull_request.labels.*.name, 'human-merge') && github.event.pull_request.user.login == 'base44-github-actions[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Only the compiled tree and the pin may change + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + git diff --name-only "$BASE" "$HEAD" > changed.txt + if grep -vE '^(skills/base44-platform-[^/]+/|SOURCE_COMMIT$)' changed.txt; then + echo "::error::the PR changes files outside the compiled tree; leaving it for a human"; exit 1 + fi + echo "$(wc -l < changed.txt) generated files changed" + - name: Enable auto-merge + env: + GH_TOKEN: ${{ github.token }} + run: gh pr merge "${{ github.event.pull_request.number }}" --squash --auto diff --git a/.github/workflows/notify-slack-skill-sync.yml b/.github/workflows/notify-slack-skill-sync.yml index 43ba011..85288cf 100644 --- a/.github/workflows/notify-slack-skill-sync.yml +++ b/.github/workflows/notify-slack-skill-sync.yml @@ -1,11 +1,12 @@ name: Notify Slack on skill-sync PRs -# When an auto-generated base44-cli / base44-sdk skill-sync PR opens, post a +# When an auto-generated skill-sync PR opens (base44-cli / base44-sdk, or the nightly +# base44-platform-* tree compiled from the Base44 builder prompt), post a # message to the team Slack channel so someone remembers to review it. Fires # only for skill-update PRs; anything else is ignored. # # Discriminator: the `auto-skill-update` label. The sync workflows -# (sync-cli-skill.yml / sync-sdk-skill.yml) create their PRs with this label, +# (sync-cli-skill.yml / sync-sdk-skill.yml here, the platform-skills sync at Base44) create their PRs with this label, # so it is present in the `opened` event payload. This is a stable, explicit # contract — independent of the PR title wording or which bot authored it. # diff --git a/.github/workflows/validate-base44-platform.yml b/.github/workflows/validate-base44-platform.yml new file mode 100644 index 0000000..1688b49 --- /dev/null +++ b/.github/workflows/validate-base44-platform.yml @@ -0,0 +1,28 @@ +name: Validate base44-platform skills + +# The `skills/base44-platform-*` tree is compiled from the Base44 builder prompt +# and synced here nightly. This check proves a PR carries exactly what the compiler emitted: +# headers, manifest hashes, frontmatter, links and the SOURCE_COMMIT pin. Make it a required check +# on main, so the auto-merge workflow only merges a tree that passed it. + +on: + pull_request: + paths: + - "skills/base44-platform-*/**" + - "SOURCE_COMMIT" + - "scripts/validate-base44-platform.mjs" + - ".github/workflows/validate-base44-platform.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@v6 + with: + node-version: '24' + - run: node scripts/validate-base44-platform.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11a4a3a..ab44915 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,6 +117,57 @@ npx skills add . --skill your-skill-name -a cursor - Prefer modern JavaScript/TypeScript syntax - Follow Base44 SDK conventions +## Compiled platform skills (`base44-platform-*`) + +The `skills/base44-platform-*` directories are **generated**. Their source is the Base44 builder's +own system prompt, rendered for an external coding agent by Base44's internal compiler and +published here by a sync workflow that Base44 runs by hand. They describe the platform exactly as +the in-editor AI learns it, one reference per app shape. + +### How the pipeline works + +1. **At Base44**: the compiler renders every prompt partial in its capability matrix for the + external surface, drops passages marked internal, and validates the result (deterministic output, + no builder-tool vocabulary, no internal names, manifest, links). An IP review then reads the tree + that would be published; a tree it flags stays inside Base44 until an engineer has read the + findings and approved it. The sync opens one PR here on the branch `auto/base44-platform-skills`, + labeled `auto-skill-update`, with the review's report in its body. No change, no PR. +2. **In this repo**: `Validate base44-platform skills` (`scripts/validate-base44-platform.mjs`) + checks that the PR carries exactly what the compiler emitted: the generated-file header on every + file, sha256 hashes matching `skills/base44-platform-manifest.json`, frontmatter whose `name` + equals the directory name, resolving links, and a 40-hex `SOURCE_COMMIT` pin. +3. `Auto-merge compiled skill updates` enables squash auto-merge on a labeled PR opened by + `base44-github-actions[bot]` whose diff is confined to `skills/base44-platform-*/**` and + `SOURCE_COMMIT`. A PR published on an engineer's approval after review findings waits for a + human merge; so does everything else (it carries the `human-merge` label, which the job skips). The Slack notifier posts the PR like any other skill-sync PR. + +### How to change a compiled skill + +Do not edit these files here: the validator fails on a hand edit, and the next sync overwrites it. +Open an issue describing the wrong or missing content, or, as a Base44 engineer, change the source +prompt: wording wrong for every agent is fixed in the shared partial; wording only an external agent +needs goes in the partial's external branch; a new capability, app shape or reference is added to the +matrix; a passage that must not be public is marked internal. The next sync carries it here. + +`SOURCE_COMMIT` names the source commit the current tree was compiled from. + +### Which reference an agent should read + +Each `base44-platform-*` skill's `SKILL.md` routes by app profile (Vite SPA, TanStack Start, native +mobile). The platform answers that question directly: +`GET /api/apps/{app_id}/agent-profile` (public API, also reachable through the Base44 MCP +`execute_api` tool) returns the app's shape, the workspace's entitlements and the exact reference +paths to read. + +### Operating the pipeline (maintainers) + +- The sync PR is opened by the `base44-github-actions` GitHub App, the same app the CLI and SDK + release workflows use to dispatch here. +- Auto-merge must be enabled in the repository settings, with `Validate base44-platform skills` as + a required check on `main`. +- `.github/CODEOWNERS` routes the generated tree and the two workflows to the owning team. +- Syncs are manual and run from Base44's side; there is no schedule in this repo. + ## Questions? - Check existing skills for examples diff --git a/README.md b/README.md index 679c133..4470a40 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,22 @@ npx skills add base44/skills --skill base44-remote-dev --skill base44-sandbox -- |-------|-------------| | [`base44-cli`](skills/base44-cli/SKILL.md) | Create and manage Base44 projects using the CLI. Handles resource configuration (entities, backend functions, realtime actors, AI agents), initialization, and deployment. | | [`base44-sdk`](skills/base44-sdk/SKILL.md) | Build apps using the Base44 JavaScript SDK. Communicate with remote resources like entities, backend functions, realtime actors, and AI agents. | -| [`base44-troubleshooter`](skills/base44-troubleshooter/SKILL.md) | Troubleshoot production issues using backend function logs. Use when investigating app errors or diagnosing production problems. | +| [`base44-troubleshooter`](skills/base44-troubleshooter/SKILL.md) | Troubleshoot production issues using backend function logs and workflow run history. Use when investigating app errors or diagnosing failed scheduled jobs. | | [`base44-remote-dev`](skills/base44-remote-dev/SKILL.md) | Develop a Base44 app remotely from your own coding agent by connecting it to the Base44 sandbox over MCP or the `base44 sandbox` CLI. | | [`base44-sandbox`](skills/base44-sandbox/SKILL.md) | Author Base44 app code inside the cloud sandbox — no deploy/push; writing a resource file (function, actor, entity, agent) into the sandbox is what ships it. | +| [`base44-platform-platform`](skills/base44-platform-platform/SKILL.md) | Platform concepts for a Base44 app: entities, functions or server code, workflows, file structure, authentication and user invites, per app type. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-entities`](skills/base44-platform-entities/SKILL.md) | Entity schemas, the entity SDK and row-level security rules, as the Base44 builder teaches them. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-backend-functions`](skills/base44-platform-backend-functions/SKILL.md) | Backend functions on Cloudflare (or TanStack Start server code): secrets, the SDK on the server, auth, webhooks, testing, the InvokeLLM model table. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-workflows`](skills/base44-platform-workflows/SKILL.md) | Workflows: trigger-driven `.jsonc` processes under `base44/workflows/` with the full authoring guide, plus the legacy automations pipeline. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-agents`](skills/base44-platform-agents/SKILL.md) | In-app AI agents: config files, tool permissions, channels, the agents SDK and code agents on the AI gateway. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-actors`](skills/base44-platform-actors/SKILL.md) | Realtime actors: stateful WebSocket rooms for multiplayer, presence, collaborative boards and chat. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-integrations-sdk`](skills/base44-platform-integrations-sdk/SKILL.md) | Built-in integrations (InvokeLLM, SendEmail, file uploads, data extraction) with every Core endpoint schema, plus analytics. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-connectors`](skills/base44-platform-connectors/SKILL.md) | App connectors: OAuth integrations in shared, app-user and BYO-shared modes, and how server code reads their tokens. Compiled from the Base44 builder's own prompt. | +| [`base44-platform-frontend`](skills/base44-platform-frontend/SKILL.md) | Frontend coding standards: conventions, installed packages, routing, Tailwind and design tokens, npm installs, per framework. Compiled from the Base44 builder's own prompt. | + +### Platform skills (compiled) + +The `base44-platform-*` skills are generated from the Base44 builder's own system prompt and synced here by Base44, so they describe the platform exactly as the in-editor AI learns it: one reference per app shape (Vite SPA, TanStack Start, native mobile). Ask the platform which shape an app is with `GET /api/apps/{app_id}/agent-profile` (also reachable through the Base44 MCP `execute_api` tool); it returns the exact reference files to read. Do not edit these files by hand: the `SOURCE_COMMIT` file records the source commit they were compiled from, and the next sync overwrites them. How the pipeline works and how to change the content: [CONTRIBUTING.md](CONTRIBUTING.md#compiled-platform-skills-base44-platform-). ## About Agent Skills diff --git a/SOURCE_COMMIT b/SOURCE_COMMIT new file mode 100644 index 0000000..6403575 --- /dev/null +++ b/SOURCE_COMMIT @@ -0,0 +1 @@ +025f298e0d9c239575381d939a226ed1cf47bfea diff --git a/plugins/base44-sandbox/.claude-plugin/plugin.json b/plugins/base44-sandbox/.claude-plugin/plugin.json index 41d5c97..5faf374 100644 --- a/plugins/base44-sandbox/.claude-plugin/plugin.json +++ b/plugins/base44-sandbox/.claude-plugin/plugin.json @@ -9,6 +9,27 @@ "homepage": "https://docs.base44.com", "repository": "https://github.com/base44/skills", "license": "MIT", - "keywords": ["base44", "sandbox", "remote-dev", "mcp", "entities", "backend-functions"], - "skills": ["./skills/base44-remote-dev/", "./skills/base44-sandbox/", "./skills/base44-sdk/", "./skills/base44-troubleshooter/"] + "keywords": [ + "base44", + "sandbox", + "remote-dev", + "mcp", + "entities", + "backend-functions" + ], + "skills": [ + "./skills/base44-remote-dev/", + "./skills/base44-sandbox/", + "./skills/base44-sdk/", + "./skills/base44-troubleshooter/", + "./skills/base44-platform-platform/", + "./skills/base44-platform-entities/", + "./skills/base44-platform-backend-functions/", + "./skills/base44-platform-workflows/", + "./skills/base44-platform-agents/", + "./skills/base44-platform-actors/", + "./skills/base44-platform-integrations-sdk/", + "./skills/base44-platform-connectors/", + "./skills/base44-platform-frontend/" + ] } diff --git a/plugins/base44-sandbox/.codex-plugin/plugin.json b/plugins/base44-sandbox/.codex-plugin/plugin.json index 76d955f..b269aab 100644 --- a/plugins/base44-sandbox/.codex-plugin/plugin.json +++ b/plugins/base44-sandbox/.codex-plugin/plugin.json @@ -9,15 +9,40 @@ "homepage": "https://docs.base44.com", "repository": "https://github.com/base44/skills", "license": "MIT", - "keywords": ["base44", "sandbox", "remote-dev", "mcp", "entities", "backend-functions"], - "skills": ["./skills/base44-remote-dev/", "./skills/base44-sandbox/", "./skills/base44-sdk/", "./skills/base44-troubleshooter/"], + "keywords": [ + "base44", + "sandbox", + "remote-dev", + "mcp", + "entities", + "backend-functions" + ], + "skills": [ + "./skills/base44-remote-dev/", + "./skills/base44-sandbox/", + "./skills/base44-sdk/", + "./skills/base44-troubleshooter/", + "./skills/base44-platform-platform/", + "./skills/base44-platform-entities/", + "./skills/base44-platform-backend-functions/", + "./skills/base44-platform-workflows/", + "./skills/base44-platform-agents/", + "./skills/base44-platform-actors/", + "./skills/base44-platform-integrations-sdk/", + "./skills/base44-platform-connectors/", + "./skills/base44-platform-frontend/" + ], "interface": { "displayName": "Base44 Sandbox", "shortDescription": "Develop Base44 apps remotely in the cloud sandbox from Codex", "longDescription": "Develop a Base44 app inside Base44's cloud sandbox where the platform auto-builds, auto-commits, and auto-syncs. Includes conventions for entities, functions, agents, and connectors, SDK development, and production troubleshooting — without deploy/push commands.", "developerName": "base44", "category": "Coding", - "capabilities": ["Interactive", "Read", "Write"], + "capabilities": [ + "Interactive", + "Read", + "Write" + ], "websiteURL": "https://base44.com", "logo": "./assets/base44-logo.png" } diff --git a/plugins/base44-sandbox/.cursor-plugin/plugin.json b/plugins/base44-sandbox/.cursor-plugin/plugin.json index 060b8cf..034171e 100644 --- a/plugins/base44-sandbox/.cursor-plugin/plugin.json +++ b/plugins/base44-sandbox/.cursor-plugin/plugin.json @@ -4,13 +4,34 @@ "version": "1.0.0-beta.1", "description": "Develop a Base44 app remotely in Base44's cloud sandbox; conventions only, no deploy/push.", "author": { - "name": "base44", - "url": "https://base44.com" + "name": "base44", + "url": "https://base44.com" }, "homepage": "https://docs.base44.com", "repository": "https://github.com/base44/skills", "license": "MIT", "logo": "./assets/base44-logo.png", - "keywords": ["base44", "sandbox", "remote-dev", "mcp", "entities", "backend-functions"], - "skills": ["./skills/base44-remote-dev/", "./skills/base44-sandbox/", "./skills/base44-sdk/", "./skills/base44-troubleshooter/"] - } + "keywords": [ + "base44", + "sandbox", + "remote-dev", + "mcp", + "entities", + "backend-functions" + ], + "skills": [ + "./skills/base44-remote-dev/", + "./skills/base44-sandbox/", + "./skills/base44-sdk/", + "./skills/base44-troubleshooter/", + "./skills/base44-platform-platform/", + "./skills/base44-platform-entities/", + "./skills/base44-platform-backend-functions/", + "./skills/base44-platform-workflows/", + "./skills/base44-platform-agents/", + "./skills/base44-platform-actors/", + "./skills/base44-platform-integrations-sdk/", + "./skills/base44-platform-connectors/", + "./skills/base44-platform-frontend/" + ] +} diff --git a/scripts/validate-base44-platform.mjs b/scripts/validate-base44-platform.mjs new file mode 100644 index 0000000..c2156b5 --- /dev/null +++ b/scripts/validate-base44-platform.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// Validates the compiled `skills/base44-platform-*` tree that Base44 publishes from its builder prompt. +// The compiler at Base44 owns the content; this proves a PR carries +// exactly what it emitted: every generated file bears the header, is listed in the manifest with +// a matching sha256, has valid frontmatter, and links only to files that exist. + +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const skillsDir = path.join(repoRoot, "skills"); +const PREFIX = "base44-platform-"; +const MANIFEST = path.join(skillsDir, `${PREFIX}manifest.json`); +const HEADER = " +# Base44 actors + +One actor instance per room id; the actor is authoritative, clients send operations. Writing the entry file deploys the actor. + +> **Requires the Builder plan or above.** The workspace's entitlement is reported by the agent-profile endpoint; on a lower plan the platform rejects the deploy. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Realtime actors (web apps)](references/web.md) | +| Web app, TanStack Start | [Realtime actors (web apps)](references/web.md) | +| Native mobile app (Expo, React Native) | [Realtime actors (mobile apps)](references/mobile.md) | diff --git a/skills/base44-platform-actors/references/mobile.md b/skills/base44-platform-actors/references/mobile.md new file mode 100644 index 0000000..bd2c6de --- /dev/null +++ b/skills/base44-platform-actors/references/mobile.md @@ -0,0 +1,122 @@ + +# Base44 actors: Realtime actors (mobile apps) + + +Realtime shared state runs on Actors: stateful server rooms over WebSockets (Durable Objects). One live instance per room id — every client connected to that id shares it. Use an actor whenever users interact live in one shared session: collaborative drawing and whiteboards, shared docs, presence and live cursors, chat rooms, live auctions, multiplayer games. Not for single-user state, not for a page that just lists records live (entity realtime subscriptions already push record changes), not for async flows (entities + functions are enough). The actor is AUTHORITATIVE: clients send inputs/operations, the actor validates, applies, and broadcasts — never trust client-computed outcomes, and never advance authoritative state client-side. + + +Write `base44/actors/{Name}/entry.ts` — the FOLDER names the actor (it is what `base44.actors.{Name}` resolves); export the class as the DEFAULT export. One actor per directory. Author plain JS like a backend function — no type annotations; the file is `.ts` only for the entry contract. Writing the file deploys it; there is no request-style test for an actor (WebSocket) — verify in the preview. + +```js +// base44/actors/BoardRoom/entry.ts (plain JS) +import { Actor } from 'base44:runtime/actors'; // the ONLY import that resolves the Actor base + +const MAX_USERS = 32; // a room is single-threaded — admit only what one instance can serve + +export default class BoardRoom extends Actor { + // Keyed by conn.id (the SDK reuses it on reconnect, so entries are reclaimable) — + // but conn.id is CLIENT-HELD and must NEVER appear in broadcasts: broadcast state + // is keyed by the server-assigned seat instead. + users = new Map(); // conn.id -> { seat, cursor } + nextSeat = 1; + items = new Map(); // the shared, persisted room state + + async handleStart() { + // Runs on ANY wake (deploy restart, idle, hibernation) — rehydrate, then + // reconcile: a hibernation wake keeps sockets ATTACHED without re-running + // handleConnect, so a still-live id must keep its seat, not lose it. + const saved = await this.storage.get('items'); + if (saved) this.items = new Map(saved); + const seats = await this.storage.get('seats'); + if (seats) this.users = new Map(seats); + const live = new Set(this.getConnections().map((c) => c.id)); + for (const id of this.users.keys()) if (!live.has(id)) this.users.delete(id); + this.nextSeat = Math.max(0, ...[...this.users.values()].map((u) => u.seat)) + 1; + } + async handleConnect(conn) { + if (!this.users.has(conn.id) && this.users.size >= MAX_USERS) { + conn.reject(4001, 'room full'); // reject closes the socket but NOT this handler — + return; // return immediately + } + // Reconnects are ROUTINE (blips, reloads, redeploys): a returning id reclaims + // its entry — never demote it or mint a new seat. + if (!this.users.has(conn.id)) { + this.users.set(conn.id, { seat: this.nextSeat++, cursor: null }); + await this.saveSeats(); // at seat milestones, never per cursor move + } + conn.send({ type: 'you', seat: this.users.get(conn.id).seat }); + conn.send({ type: 'state', items: [...this.items.values()] }); // late joiners get full state + this.broadcastPresence(); + } + async handleMessage(conn, msg) { + // Runtime-validate EVERYTHING: the wire payload is attacker-controlled and + // msg can even be null. Accept operations, never authoritative state. + if (typeof msg !== 'object' || msg === null) return; + const u = this.users.get(conn.id); + if (!u) return; + if (msg.type === 'cursor') { + u.cursor = [Number(msg.x) || 0, Number(msg.y) || 0]; + this.broadcastPresence(); + } else if (msg.type === 'upsert_item' && typeof msg.id === 'string' && msg.id.length <= 64) { + const item = { id: msg.id, text: String(msg.text ?? '').slice(0, 2000) }; + this.items.set(item.id, item); + await this.storage.put('items', [...this.items.entries()]); // persist on change — never on every pointer move + this.broadcast({ type: 'item', item }); + } + } + async handleClose(conn) { + this.users.delete(conn.id); + await this.saveSeats(); + this.broadcastPresence(); + } + saveSeats() { return this.storage.put('seats', [...this.users.entries()]); } + broadcastPresence() { + // PROJECT an explicit public shape — never spread whole server objects into a + // broadcast (they grow per-user secrets later). + this.broadcast({ + type: 'presence', + users: [...this.users.values()].map((u) => ({ seat: u.seat, cursor: u.cursor })), + }); + } +} +``` + + + +- Handlers: `handleConnect` / `handleMessage` / `handleClose`, plus optional `handleStart` (any wake) and `handleWake(key)` (scheduled wakes). Never override `onStart` or `onAlarm` — platform plumbing. +- State: instance fields reset when the room idles out — a room hibernates after ~10s of quiet EVEN with clients connected; `this.storage` survives. Persist state you can't lose when it changes and rehydrate it in `handleStart()`; never write high-frequency churn (every pointer move, every keystroke) to storage. A hibernation wake keeps sockets ATTACHED and does NOT re-run `handleConnect`, so reconcile the rehydrated state against `this.getConnections()` — skip that and every connected client stays unrecognized until it reconnects. (`static options = { hibernate: false }` only makes the room non-hibernatable, NOT resident: it is still evicted after ~1-2 minutes idle, so storage is still the only durable answer. Rarely needed.) +- `conn.id` is the per-connection identity. The SDK reuses it across auto-reconnects, and a persisted `connect({ id })` (see the client) makes reloads reclaim it too — key seats/roles by it and let a returning id RECLAIM its entry with its role and work intact. When a drop shouldn't discard that work, give the id a short grace before wiping its state; when the LAST client leaves mid-session, schedule the cleanup as a wake and cancel it on reconnect. conn.id is client-held: fine for seats and reconnects, NEVER attribution — that is `conn.identity`, platform-verified: `{ type: 'authenticated', userId }` or `{ type: 'anonymous', anonymousId }` (a visitor, not a user). +- Scheduled wakes: `this.schedule(key, atEpochMs)` persists a named wake; `handleWake(key)` runs at/after it EVEN IF THE ROOM IS EMPTY AND ASLEEP. `cancelSchedule(key)` cancels; re-scheduling a key overwrites. Coarse (±seconds) and fires at most once — use for deadline timers (auction close, session expiry), delayed cleanup of abandoned rooms, absolute-time events; in-session countdowns stay timestamp-driven. +- `this.broadcast(...)` is for ROOM-wide state; events about ONE client (your assigned color, your validation error, your private draft) go `conn.send(...)` — broadcasting them leaks private state and makes every client react. Messages are JSON both ways; `type` values starting with `__` are platform-reserved. +- `this.client` is a ready-made `@base44/sdk` client on every actor (`await this.client.functions.invoke('name', input)` — there is no `functions.name()` form, and the returned value sits on `.data`; `await this.client.entities.Thing.filter(...)`) acting as the app's ANONYMOUS role, pinned to the actor's own deploy. `this.client.asServiceRole` (`entities`, `functions`, `integrations`) is the admin-level client, as in a backend function: use it for work the ROOM owns and has validated — persisting its durable result, maintaining its registry row, reading private config — never to do what a client asked without validating it, and never loosen RLS to the anonymous role instead. Neither client is a signed-in user: attribute service-role records with an explicit field from `conn.identity.userId` (authenticated identities only). The first privileged call after a wake pays a token exchange — keep it on persist paths, not per message. +- Durable results (the finished drawing, a chat transcript, an exported document): the actor broadcasts the authoritative result, writes it to `this.storage`, and persists it to entities via `asServiceRole`, keyed by the room instance id and IDEMPOTENT (check for the existing record before creating; readers treat the earliest record per key as canonical) — delivery and wakes are at-least-once. Re-`conn.send` it to (re)connecting clients: shells cannot read actor storage. The shell writes only records the USER owns, never the room's canonical result. +- Actor auth is automatic. Never ask the user for `ACTOR_TOKEN_SECRET` or set it yourself; it is not readable from function or actor env. +- Rooms and discovery: one room = one session (a drawing board, a chat channel, a document) — never funnel everyone into a single global room. For browsable rooms keep a registry ENTITY (e.g. Room: status, user_count) whose record id IS the actor instance id; the registry is advertising, the actor is truth — admission is decided in `handleConnect`, never from the row. List rooms by subscribing to the entity FIRST, then fetching and reconciling by id; filter to recently-updated rows (crashed rooms leave stale ones). PRIVATE rooms: there is no room-level auth — the instance id is the only admission control, so mint it with `crypto.randomUUID()` (record ids are guessable), never list it in a readable registry, and share it only as an invite link/code. + + + +```js +// React shell. Connect in useEffect WITH cleanup — a bare connect in the component +// body leaks a socket per render. +import { base44 } from '@/api/base44Client'; + +useEffect(() => { + // Persist the conn id per tab so a page RELOAD reclaims the same seat; a fresh + // per-render id would leak seats on every refresh. + let connId = sessionStorage.getItem('connId'); + if (!connId) { connId = crypto.randomUUID(); sessionStorage.setItem('connId', connId); } + const room = base44.actors.BoardRoom(roomId).connect({ id: connId }); // the handle IS the connection + const sub = room.subscribe((msg) => { + // Branch on msg.type into React state; drop unknown types. + }); + return () => { sub.unsubscribe(); room.close(); }; +}, [roomId]); +// Send operations on change (room.send({ type: 'upsert_item', ... })); throttle +// high-frequency input like cursors or brush strokes (~20-30/s max), never per render. +``` +Reconnection, heartbeats, and auth are handled by the SDK. +React Native: the same SDK API applies; there is no `sessionStorage` — keep the conn id in module scope or state (a fresh launch mints a new identity). + + diff --git a/skills/base44-platform-actors/references/web.md b/skills/base44-platform-actors/references/web.md new file mode 100644 index 0000000..6f82805 --- /dev/null +++ b/skills/base44-platform-actors/references/web.md @@ -0,0 +1,121 @@ + +# Base44 actors: Realtime actors (web apps) + + +Realtime shared state runs on Actors: stateful server rooms over WebSockets (Durable Objects). One live instance per room id — every client connected to that id shares it. Use an actor whenever users interact live in one shared session: collaborative drawing and whiteboards, shared docs, presence and live cursors, chat rooms, live auctions, multiplayer games. Not for single-user state, not for a page that just lists records live (entity realtime subscriptions already push record changes), not for async flows (entities + functions are enough). The actor is AUTHORITATIVE: clients send inputs/operations, the actor validates, applies, and broadcasts — never trust client-computed outcomes, and never advance authoritative state client-side. + + +Write `base44/actors/{Name}/entry.ts` — the FOLDER names the actor (it is what `base44.actors.{Name}` resolves); export the class as the DEFAULT export. One actor per directory. Author plain JS like a backend function — no type annotations; the file is `.ts` only for the entry contract. Writing the file deploys it; there is no request-style test for an actor (WebSocket) — verify in the preview. + +```js +// base44/actors/BoardRoom/entry.ts (plain JS) +import { Actor } from 'base44:runtime/actors'; // the ONLY import that resolves the Actor base + +const MAX_USERS = 32; // a room is single-threaded — admit only what one instance can serve + +export default class BoardRoom extends Actor { + // Keyed by conn.id (the SDK reuses it on reconnect, so entries are reclaimable) — + // but conn.id is CLIENT-HELD and must NEVER appear in broadcasts: broadcast state + // is keyed by the server-assigned seat instead. + users = new Map(); // conn.id -> { seat, cursor } + nextSeat = 1; + items = new Map(); // the shared, persisted room state + + async handleStart() { + // Runs on ANY wake (deploy restart, idle, hibernation) — rehydrate, then + // reconcile: a hibernation wake keeps sockets ATTACHED without re-running + // handleConnect, so a still-live id must keep its seat, not lose it. + const saved = await this.storage.get('items'); + if (saved) this.items = new Map(saved); + const seats = await this.storage.get('seats'); + if (seats) this.users = new Map(seats); + const live = new Set(this.getConnections().map((c) => c.id)); + for (const id of this.users.keys()) if (!live.has(id)) this.users.delete(id); + this.nextSeat = Math.max(0, ...[...this.users.values()].map((u) => u.seat)) + 1; + } + async handleConnect(conn) { + if (!this.users.has(conn.id) && this.users.size >= MAX_USERS) { + conn.reject(4001, 'room full'); // reject closes the socket but NOT this handler — + return; // return immediately + } + // Reconnects are ROUTINE (blips, reloads, redeploys): a returning id reclaims + // its entry — never demote it or mint a new seat. + if (!this.users.has(conn.id)) { + this.users.set(conn.id, { seat: this.nextSeat++, cursor: null }); + await this.saveSeats(); // at seat milestones, never per cursor move + } + conn.send({ type: 'you', seat: this.users.get(conn.id).seat }); + conn.send({ type: 'state', items: [...this.items.values()] }); // late joiners get full state + this.broadcastPresence(); + } + async handleMessage(conn, msg) { + // Runtime-validate EVERYTHING: the wire payload is attacker-controlled and + // msg can even be null. Accept operations, never authoritative state. + if (typeof msg !== 'object' || msg === null) return; + const u = this.users.get(conn.id); + if (!u) return; + if (msg.type === 'cursor') { + u.cursor = [Number(msg.x) || 0, Number(msg.y) || 0]; + this.broadcastPresence(); + } else if (msg.type === 'upsert_item' && typeof msg.id === 'string' && msg.id.length <= 64) { + const item = { id: msg.id, text: String(msg.text ?? '').slice(0, 2000) }; + this.items.set(item.id, item); + await this.storage.put('items', [...this.items.entries()]); // persist on change — never on every pointer move + this.broadcast({ type: 'item', item }); + } + } + async handleClose(conn) { + this.users.delete(conn.id); + await this.saveSeats(); + this.broadcastPresence(); + } + saveSeats() { return this.storage.put('seats', [...this.users.entries()]); } + broadcastPresence() { + // PROJECT an explicit public shape — never spread whole server objects into a + // broadcast (they grow per-user secrets later). + this.broadcast({ + type: 'presence', + users: [...this.users.values()].map((u) => ({ seat: u.seat, cursor: u.cursor })), + }); + } +} +``` + + + +- Handlers: `handleConnect` / `handleMessage` / `handleClose`, plus optional `handleStart` (any wake) and `handleWake(key)` (scheduled wakes). Never override `onStart` or `onAlarm` — platform plumbing. +- State: instance fields reset when the room idles out — a room hibernates after ~10s of quiet EVEN with clients connected; `this.storage` survives. Persist state you can't lose when it changes and rehydrate it in `handleStart()`; never write high-frequency churn (every pointer move, every keystroke) to storage. A hibernation wake keeps sockets ATTACHED and does NOT re-run `handleConnect`, so reconcile the rehydrated state against `this.getConnections()` — skip that and every connected client stays unrecognized until it reconnects. (`static options = { hibernate: false }` only makes the room non-hibernatable, NOT resident: it is still evicted after ~1-2 minutes idle, so storage is still the only durable answer. Rarely needed.) +- `conn.id` is the per-connection identity. The SDK reuses it across auto-reconnects, and a persisted `connect({ id })` (see the client) makes reloads reclaim it too — key seats/roles by it and let a returning id RECLAIM its entry with its role and work intact. When a drop shouldn't discard that work, give the id a short grace before wiping its state; when the LAST client leaves mid-session, schedule the cleanup as a wake and cancel it on reconnect. conn.id is client-held: fine for seats and reconnects, NEVER attribution — that is `conn.identity`, platform-verified: `{ type: 'authenticated', userId }` or `{ type: 'anonymous', anonymousId }` (a visitor, not a user). +- Scheduled wakes: `this.schedule(key, atEpochMs)` persists a named wake; `handleWake(key)` runs at/after it EVEN IF THE ROOM IS EMPTY AND ASLEEP. `cancelSchedule(key)` cancels; re-scheduling a key overwrites. Coarse (±seconds) and fires at most once — use for deadline timers (auction close, session expiry), delayed cleanup of abandoned rooms, absolute-time events; in-session countdowns stay timestamp-driven. +- `this.broadcast(...)` is for ROOM-wide state; events about ONE client (your assigned color, your validation error, your private draft) go `conn.send(...)` — broadcasting them leaks private state and makes every client react. Messages are JSON both ways; `type` values starting with `__` are platform-reserved. +- `this.client` is a ready-made `@base44/sdk` client on every actor (`await this.client.functions.invoke('name', input)` — there is no `functions.name()` form, and the returned value sits on `.data`; `await this.client.entities.Thing.filter(...)`) acting as the app's ANONYMOUS role, pinned to the actor's own deploy. `this.client.asServiceRole` (`entities`, `functions`, `integrations`) is the admin-level client, as in a backend function: use it for work the ROOM owns and has validated — persisting its durable result, maintaining its registry row, reading private config — never to do what a client asked without validating it, and never loosen RLS to the anonymous role instead. Neither client is a signed-in user: attribute service-role records with an explicit field from `conn.identity.userId` (authenticated identities only). The first privileged call after a wake pays a token exchange — keep it on persist paths, not per message. +- Durable results (the finished drawing, a chat transcript, an exported document): the actor broadcasts the authoritative result, writes it to `this.storage`, and persists it to entities via `asServiceRole`, keyed by the room instance id and IDEMPOTENT (check for the existing record before creating; readers treat the earliest record per key as canonical) — delivery and wakes are at-least-once. Re-`conn.send` it to (re)connecting clients: shells cannot read actor storage. The shell writes only records the USER owns, never the room's canonical result. +- Actor auth is automatic. Never ask the user for `ACTOR_TOKEN_SECRET` or set it yourself; it is not readable from function or actor env. +- Rooms and discovery: one room = one session (a drawing board, a chat channel, a document) — never funnel everyone into a single global room. For browsable rooms keep a registry ENTITY (e.g. Room: status, user_count) whose record id IS the actor instance id; the registry is advertising, the actor is truth — admission is decided in `handleConnect`, never from the row. List rooms by subscribing to the entity FIRST, then fetching and reconciling by id; filter to recently-updated rows (crashed rooms leave stale ones). PRIVATE rooms: there is no room-level auth — the instance id is the only admission control, so mint it with `crypto.randomUUID()` (record ids are guessable), never list it in a readable registry, and share it only as an invite link/code. + + + +```js +// React shell. Connect in useEffect WITH cleanup — a bare connect in the component +// body leaks a socket per render. +import { base44 } from '@/api/base44Client'; + +useEffect(() => { + // Persist the conn id per tab so a page RELOAD reclaims the same seat; a fresh + // per-render id would leak seats on every refresh. + let connId = sessionStorage.getItem('connId'); + if (!connId) { connId = crypto.randomUUID(); sessionStorage.setItem('connId', connId); } + const room = base44.actors.BoardRoom(roomId).connect({ id: connId }); // the handle IS the connection + const sub = room.subscribe((msg) => { + // Branch on msg.type into React state; drop unknown types. + }); + return () => { sub.unsubscribe(); room.close(); }; +}, [roomId]); +// Send operations on change (room.send({ type: 'upsert_item', ... })); throttle +// high-frequency input like cursors or brush strokes (~20-30/s max), never per render. +``` +Reconnection, heartbeats, and auth are handled by the SDK. + + diff --git a/skills/base44-platform-agents/SKILL.md b/skills/base44-platform-agents/SKILL.md new file mode 100644 index 0000000..afd2e4e --- /dev/null +++ b/skills/base44-platform-agents/SKILL.md @@ -0,0 +1,23 @@ +--- +name: base44-platform-agents +description: "Base44 in-app AI agents: JSON agent configs under base44/agents/ with entity, backend-function or server-route tools, WhatsApp and Telegram channels, the agents SDK for conversation UIs, and code agents (an agent loop the app runs on the AI gateway). Use when the app needs a conversational assistant or programmable AI." +--- + +# Base44 agents + +An agent is a `.jsonc` file under `base44/agents/`; writing it defines the agent. Build the conversation UI with the agents SDK, or run a code agent on the AI gateway. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Agents (Vite SPA: tools are backend functions)](references/web-spa.md) | +| Web app, TanStack Start | [Agents (TanStack Start: tools are server routes)](references/web-tanstack.md) | diff --git a/skills/base44-platform-agents/references/web-spa.md b/skills/base44-platform-agents/references/web-spa.md new file mode 100644 index 0000000..b973494 --- /dev/null +++ b/skills/base44-platform-agents/references/web-spa.md @@ -0,0 +1,195 @@ + +# Base44 agents: Agents (Vite SPA: tools are backend functions) + + +You can create AI agents when the user asks for them. InvokeLLM is a lighter-weight LLM call; agents fit tasks needing entity access, complex workflows, and more. + +An agent is a JSON configuration file in the base44/agents/ directory; to delete an agent, delete its config file. + +``` +// base44/agents/task_manager.jsonc +{ + "description": "This is an agent that can edit and manage tasks in the app", + "instructions": "Given a user request, you will edit and manage tasks in the app", + "tool_configs": [] +} +``` + + + +Agents act as the current app user, but get no app data/function tools automatically. + +New agents start with `"tool_configs": []`. Add only the entity/backend-function permissions the agent actually needs to `tool_configs`, and app-user connector permissions to `app_user_connector_configs` (`{"connector_id": "", "enabled": true}`); tell the user which permissions you granted and why. When later adding a backend function, connector-link capability, or entity capability to an existing agent, edit that agent's config file only — leave unrelated agents untouched. + +App-user OAuth connector permission only exposes connector context and the connect-link tool — it does not grant API access and is not required for backend function tools. Request it when the agent should help app users connect or reconnect that account. + +tool_configs example: + +```json +[ + {"entity_name": "ToDo", "allowed_operations": ["create", "update", "delete", "read"]}, + {"entity_name": "Participant", "allowed_operations": ["read"]}, + {"function_name": "sendReminder", "description": "Send a reminder notification for a task"} +] +``` + +Entity entries take `entity_name` + `allowed_operations`; backend-function entries take `function_name` + `description`. Allowed operations: "create", "update", "delete", "read" only. Tool configs support entity tools and backend function tools only. Every agent has built-in web_search — no need to define it. + + + +Agents support WhatsApp, Telegram and LINE — add a channel only when explicitly requested. When you do, set its greeting in the agent's config file — `whatsapp_greeting` / `telegram_greeting` / `line_greeting` (the message shown when an app user first messages the agent on that channel) — and, for WhatsApp and Telegram, add the connect link in the UI. The links handle authentication automatically; Telegram requires a bot connected via the agent editor's Telegram tab first. LINE has no app connect link: its activation codes come only from the agent editor's LINE tab (shown only in some regions) for app editors and expire in 30 minutes, so an in-app LINE button cannot connect app users. When asked for one, say so instead of building a button that cannot work. + +```jsx +import { base44 } from "@/api/base44Client"; +WhatsApp +Telegram +``` + + + +Without an in-app UI, an agent is only usable from the dashboard — so when you build an agent, also build the UI for its conversations in the app. + + +Manage conversations, add messages, and subscribe to updates: + +```js +import { base44 } from "@/api/base44Client"; + +const conversation = base44.agents.createConversation({ agent_name: "task_manager", metadata: { name: "My Conversation", description: "My Conversation" } }); +const conversations = base44.agents.listConversations({ agent_name: "task_manager" }); +const conversation = base44.agents.getConversation(conversationId); // conversation.messages = full history, for loading into the UI +base44.agents.addMessage(conversation, { role: "user", content: "Hello, how are you?" }); // pass the entire conversation object +base44.agents.addMessage(conversation, { role: "user", content: "Hello", file_urls: ["...url1"] }); // upload files first, then pass URLs + +// Subscribe to updates — called on every streamed token; no API calls / heavy work in the callback +useEffect(() => { + const unsubscribe = base44.agents.subscribeToConversation(conversationId, (data) => { + setMessages(data.messages); + }); + return () => unsubscribe(); +}, [conversationId]); +``` + + + +Message bubble essentials — message: { role ('user' or assistant), content (render assistant content as markdown), tool_calls? }. Each toolCall: { name, status: 'pending'|'running'|'in_progress'|'completed'|'success'|'failed'|'error', arguments_string, results (JSON string or object — JSON.parse with fallback to raw), display_projection? }. Treat as failed when status is failed/error, or results match /error|failed/i, or parsedResults.success === false. display_projection: { hide_details, details_redacted, label, active_label, error_label } — when hide_details && details_redacted are both set, render only the state label (active_label while pending/running/in_progress, error_label on failure, label on success) and hide parameters/results. + + +```jsx +const FunctionDisplay = ({ toolCall }) => { + const [expanded, setExpanded] = useState(false); + // derive status icon/text per the status mapping above; honor display_projection hide mode + return ( +
+ + {expanded && ( +
+ {/* "Parameters:" — pretty-print JSON.parse(toolCall.arguments_string), fallback raw */} + {/* "Result:" — pretty-print parsedResults */} +
+ )} +
+ ); +}; + +export default function MessageBubble({ message }) { + const isUser = message.role === 'user'; + return ( +
+ {message.content && (isUser + ?

{message.content}

+ : {message.content})} + {message.tool_calls?.map((toolCall, idx) => )} +
+ ); +} +``` +
+ +
+ +In-app agents are managed and conversational: app users talk to them and the platform runs the agent loop for you. A code agent is the programmable counterpart — a backend function running an agent loop the app owns: your code decides what tools it has (any code you write), how it runs (state across steps, step limits, which model), and what happens with the result — it's backend work the app runs, not a conversation. Build one when AI is part of the app's machinery rather than a conversation surface. Don't simulate it by chaining InvokeLLM calls or pre-fetching everything into one call — that hard-codes the decisions the AI was supposed to make. It runs against the platform’s AI gateway — same models and credit quota, no API key — and is stateless between invocations: persistent memory means storing and replaying state (e.g. in an entity). Use model "automatic" unless the task needs a specific model — non-default models use more credits: only when needed, and tell the user. + +The gateway speaks OpenAI chat completions, so any agent SDK works against it. Example with the AI SDK — a background reviewer the app runs on a return request: + +```js +import { createClientFromRequest } from "npm:@base44/sdk@0.8.54"; +import { ToolLoopAgent, tool, stepCountIs, hasToolCall } from "npm:ai@7.0.16"; +import { createOpenAICompatible } from "npm:@ai-sdk/openai-compatible@3.0.5"; +import { z } from "npm:zod@4.4.3"; + +export default async function(req) { + const base44 = createClientFromRequest(req); + const user = await base44.auth.me(); + if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const { return_id } = await req.json(); + const request = await base44.entities.ReturnRequest.get(return_id); + + const { baseURL, token, headers } = base44.asServiceRole.aiGateway.connection(); + const base44Models = createOpenAICompatible({ name: "base44", baseURL, apiKey: token, headers }); + + const agent = new ToolLoopAgent({ + model: base44Models("automatic"), + instructions: "Decide whether this return looks fine or needs the owner's attention. Check the customer's past orders as many times as you need, then submit your verdict.", + tools: { + searchOrders: tool({ + description: "This customer's past orders, optionally filtered by status", + inputSchema: z.object({ status: z.string().optional() }), + execute: ({ status }) => { + const query = { customer_email: request.customer_email }; + if (status) query.status = status; + return base44.entities.Order.filter(query, "-created_date", 50); + }, + }), + submitVerdict: tool({ + description: "Record the final verdict. Call this once, when you've decided.", + inputSchema: z.object({ decision: z.enum(["approved", "flagged"]), reason: z.string() }), + execute: ({ decision, reason }) => + base44.entities.ReturnRequest.update(return_id, { status: decision, review_note: reason }), + }), + }, + // stops at the first to trigger — submitVerdict was called, or 8 steps elapsed; + // mix whatever conditions fit the task (a step cap, a finish tool, a custom condition) + stopWhen: [stepCountIs(8), hasToolCall("submitVerdict")], + }); + + await agent.generate({ prompt: `Review this return request: ${JSON.stringify(request)}` }); + return Response.json({ ok: true }); +} +``` + +Images: when the agent needs to see an image, pass it as an image part in `messages`: + +```js +const { text } = await agent.generate({ messages: [{ role: "user", content: [ + { type: "text", text: `Review this receipt: ${JSON.stringify(receipt)}` }, + { type: "image", image: receipt.image_url }, +]}]}); +``` + +Always bound the loop — pick the stop strategy from the task's intent, giving it enough room to finish while still stopping a runaway; every step is another model call, so end the loop once the work is done. + +All other backend-function rules (deployment, secrets, error handling) apply as in the backend_functions guide. + + +Model values for base44.integrations.Core.InvokeLLM in the generated app: + +The SDK reference includes compatibility aliases. Use this catalog for current model IDs and capability constraints. + +Omit model (or use "automatic") unless the app needs an explicit model. Automatic selection supports text, uploaded files, and web search. + +| model | file_urls | add_context_from_internet | +|---|---|---| +| gemini_3_8_flash | yes | yes | +| gpt_6_luna | yes | no | +| claude-sonnet-5 | yes | no | +| claude_opus_5_5 | yes | no | +| gpt_6_sol | yes | no | +| gpt_6_astra | yes | no | +| claude_fable_5_1 | yes | no | +| glm_5_2 | no | no | + +An incompatible explicit model and capability combination raises an error. Non-default models use more integration credits; tell the user when choosing one. diff --git a/skills/base44-platform-agents/references/web-tanstack.md b/skills/base44-platform-agents/references/web-tanstack.md new file mode 100644 index 0000000..bec8140 --- /dev/null +++ b/skills/base44-platform-agents/references/web-tanstack.md @@ -0,0 +1,184 @@ + +# Base44 agents: Agents (TanStack Start: tools are server routes) + + +You can create AI agents when the user asks for them. InvokeLLM is a lighter-weight LLM call; agents fit tasks needing entity access, complex workflows, and more. + +An agent is a JSON configuration file in the base44/agents/ directory; to delete an agent, delete its config file. + +``` +// base44/agents/task_manager.jsonc +{ + "description": "This is an agent that can edit and manage tasks in the app", + "instructions": "Given a user request, you will edit and manage tasks in the app", + "tool_configs": [] +} +``` + + + +Agents act as the current app user, but get no app data/server-route tools automatically. + +New agents start with `"tool_configs": []`. Add only the entity/server-route permissions the agent actually needs to `tool_configs`, and app-user connector permissions to `app_user_connector_configs` (`{"connector_id": "", "enabled": true}`); tell the user which permissions you granted and why. When later adding a server route, connector-link capability, or entity capability to an existing agent, edit that agent's config file only — leave unrelated agents untouched. + +App-user OAuth connector permission only exposes connector context and the connect-link tool — it does not grant API access and is not required for server route tools. Request it when the agent should help app users connect or reconnect that account. + +tool_configs example: + +```json +[ + {"entity_name": "ToDo", "allowed_operations": ["create", "update", "delete", "read"]}, + {"entity_name": "Participant", "allowed_operations": ["read"]}, + {"tool_name": "send_reminder", "route_path": "/api/agent/send-reminder", "method": "post", "description": "Send a reminder notification for a task. Body: {taskId: string}. Returns {sent: boolean}."} +] +``` + +Entity entries take `entity_name` + `allowed_operations`; server-route entries take `tool_name` (`[A-Za-z0-9_-]`, ≤64), `route_path`, `method` (lowercase: `get`, `post`, `put`, `patch` or `delete`; default `post`) + `description`. Allowed operations: "create", "update", "delete", "read" only. Tool configs support entity tools and server route tools only. Every agent has built-in web_search — no need to define it. + +A server-route tool calls one of this app's server routes (`src/routes/api/…`) as the chatting app user, so the route's `requireUserRoute` and `context.getBase44()` see that user: a `GET` route receives the tool's `query` as its query string; other methods also receive `body` as the JSON request body. Agent logic that needs server code gets its own route, written for the agent: JSON in, a compact JSON result out. The agent sees only `description`, so state when to call it, the exact input fields and what it returns. + + + +Agents support WhatsApp, Telegram and LINE — add a channel only when explicitly requested. When you do, set its greeting in the agent's config file — `whatsapp_greeting` / `telegram_greeting` / `line_greeting` (the message shown when an app user first messages the agent on that channel) — and, for WhatsApp and Telegram, add the connect link in the UI. The links handle authentication automatically; Telegram requires a bot connected via the agent editor's Telegram tab first. LINE has no app connect link: its activation codes come only from the agent editor's LINE tab (shown only in some regions) for app editors and expire in 30 minutes, so an in-app LINE button cannot connect app users. When asked for one, say so instead of building a button that cannot work. + +```jsx +import { base44 } from "@/api/base44Client"; +WhatsApp +Telegram +``` + + + +Without an in-app UI, an agent is only usable from the dashboard — so when you build an agent, also build the UI for its conversations in the app. + + +Manage conversations, add messages, and subscribe to updates: + +```js +import { base44 } from "@/api/base44Client"; + +const conversation = base44.agents.createConversation({ agent_name: "task_manager", metadata: { name: "My Conversation", description: "My Conversation" } }); +const conversations = base44.agents.listConversations({ agent_name: "task_manager" }); +const conversation = base44.agents.getConversation(conversationId); // conversation.messages = full history, for loading into the UI +base44.agents.addMessage(conversation, { role: "user", content: "Hello, how are you?" }); // pass the entire conversation object +base44.agents.addMessage(conversation, { role: "user", content: "Hello", file_urls: ["...url1"] }); // upload files first, then pass URLs + +// Subscribe to updates — called on every streamed token; no API calls / heavy work in the callback +useEffect(() => { + const unsubscribe = base44.agents.subscribeToConversation(conversationId, (data) => { + setMessages(data.messages); + }); + return () => unsubscribe(); +}, [conversationId]); +``` + + + +Message bubble essentials — message: { role ('user' or assistant), content (render assistant content as markdown), tool_calls? }. Each toolCall: { name, status: 'pending'|'running'|'in_progress'|'completed'|'success'|'failed'|'error', arguments_string, results (JSON string or object — JSON.parse with fallback to raw), display_projection? }. Treat as failed when status is failed/error, or results match /error|failed/i, or parsedResults.success === false. display_projection: { hide_details, details_redacted, label, active_label, error_label } — when hide_details && details_redacted are both set, render only the state label (active_label while pending/running/in_progress, error_label on failure, label on success) and hide parameters/results. + + +```jsx +const FunctionDisplay = ({ toolCall }) => { + const [expanded, setExpanded] = useState(false); + // derive status icon/text per the status mapping above; honor display_projection hide mode + return ( +
+ + {expanded && ( +
+ {/* "Parameters:" — pretty-print JSON.parse(toolCall.arguments_string), fallback raw */} + {/* "Result:" — pretty-print parsedResults */} +
+ )} +
+ ); +}; + +export default function MessageBubble({ message }) { + const isUser = message.role === 'user'; + return ( +
+ {message.content && (isUser + ?

{message.content}

+ : {message.content})} + {message.tool_calls?.map((toolCall, idx) => )} +
+ ); +} +``` +
+ +
+ +In-app agents are managed and conversational: app users talk to them and the platform runs the agent loop for you. A code agent is the programmable counterpart — an agent loop this app runs in its own server with TanStack AI (preinstalled): your code decides what tools it has (any server code), how many steps it takes, which model, and what happens with the result. Build one when AI is part of the app's machinery, or when the app needs its own AI chat with custom tools. Don't simulate it by chaining InvokeLLM calls or pre-fetching everything into one call — that hard-codes the decisions the AI was supposed to make. It runs against the platform’s AI gateway — same models and credit quota, no API key, streaming supported — and is stateless between requests: persistent memory means storing and replaying state (e.g. in an entity). Use model "automatic" unless the task needs a specific model — non-default models use more credits: only when needed, and tell the user. + +Build the adapter with `gatewayModel(base44, model)` from `src/lib/ai.server.js`, in server code only, passing `context.getBase44()`. It calls the gateway as the app (service role), since the gateway only accepts server-side service-role calls, so authenticate the caller first. Tools keep using `context.getBase44()` and act as the caller. Every call spends the app owner's credits, so require a signed-in caller. A streaming chat is a server function that returns the `chat(...)` stream, plus `useChat` in the page: + +```js +// src/lib/chat.js +import { chat, maxIterations, toolDefinition } from "@tanstack/ai"; +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { gatewayModel } from "@/lib/ai.server"; +import { requireUser } from "@/lib/auth-middleware"; + +export const chatWithAssistant = createServerFn({ method: "POST" }) + .middleware([requireUser]) + .validator(z.object({ messages: z.array(z.any()).max(40) })) + .handler(({ data, context }) => { + const base44 = context.getBase44(); + const listOrders = toolDefinition({ + name: "listOrders", + description: "The caller's recent orders, optionally filtered by status", + inputSchema: z.object({ status: z.string().optional() }), + }).server(({ status }) => base44.entities.Order.filter(status ? { status } : {}, "-created_date", 20)); + + return chat({ + adapter: gatewayModel(base44), + messages: data.messages, + systemPrompts: ["You help customers with their orders."], + tools: [listOrders], + agentLoopStrategy: maxIterations(5), + }); + }); +``` + +```jsx +// the page (per-user, so the route sets ssr: false) +import { useChat } from "@tanstack/ai-react"; +import { chatWithAssistant } from "@/lib/chat"; + +const { messages, sendMessage, isLoading } = useChat({ + // send only the recent history: the server caps it, and a long chat would otherwise fail every turn + fetcher: ({ messages }, { signal }) => chatWithAssistant({ data: { messages: messages.slice(-40) }, signal }), +}); +// each message is { id, role, parts }: render part.content where part.type === "text" +``` + +Without a chat (a background reviewer, a classifier), run the loop in a server function guarded by `.middleware([requireUser])`: `await chat({ adapter, messages, tools, agentLoopStrategy, stream: false })` returns the final text, or pass `outputSchema` (a zod schema) instead of `stream: false` for typed JSON. + +Images: pass an image part in a message's `content`: `[{ type: "text", content: "Review this receipt" }, { type: "image", source: { type: "url", value: receipt.image_url } }]`. + +Always bound the loop with `agentLoopStrategy: maxIterations(n)` — pick n from the task's intent, giving it enough room to finish while still stopping a runaway; every step is another model call. + + +Model values for base44.integrations.Core.InvokeLLM in the generated app: + +The SDK reference includes compatibility aliases. Use this catalog for current model IDs and capability constraints. + +Omit model (or use "automatic") unless the app needs an explicit model. Automatic selection supports text, uploaded files, and web search. + +| model | file_urls | add_context_from_internet | +|---|---|---| +| gemini_3_8_flash | yes | yes | +| gpt_6_luna | yes | no | +| claude-sonnet-5 | yes | no | +| claude_opus_5_5 | yes | no | +| gpt_6_sol | yes | no | +| gpt_6_astra | yes | no | +| claude_fable_5_1 | yes | no | +| glm_5_2 | no | no | + +An incompatible explicit model and capability combination raises an error. Non-default models use more integration credits; tell the user when choosing one. diff --git a/skills/base44-platform-backend-functions/SKILL.md b/skills/base44-platform-backend-functions/SKILL.md new file mode 100644 index 0000000..0485e27 --- /dev/null +++ b/skills/base44-platform-backend-functions/SKILL.md @@ -0,0 +1,26 @@ +--- +name: base44-platform-backend-functions +description: "Base44 server-side code: backend functions (HTTP handlers in base44/functions//entry.ts on Cloudflare Workers) for Vite SPA apps, or TanStack Start server functions and server routes. Secrets, the Base44 SDK on the server, auth and service role, webhooks, admin-only handlers, frontend invocation, testing, and the InvokeLLM model table. Use when the app needs external APIs, privileged logic or scheduled work." +--- + +# Base44 backend functions + +A Vite SPA app runs server code as backend functions; a TanStack Start app runs it as server functions and server routes inside its own project. Writing the file deploys it. + +> **Requires the Builder plan or above.** The workspace's entitlement is reported by the agent-profile endpoint; on a lower plan the platform rejects the deploy. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Backend functions (Vite SPA)](references/web-spa.md) | +| Web app, TanStack Start | [Server functions and server routes (TanStack Start)](references/web-tanstack.md) | +| Native mobile app (Expo, React Native) | [Backend functions (mobile apps)](references/mobile.md) | diff --git a/skills/base44-platform-backend-functions/references/mobile.md b/skills/base44-platform-backend-functions/references/mobile.md new file mode 100644 index 0000000..0ea7414 --- /dev/null +++ b/skills/base44-platform-backend-functions/references/mobile.md @@ -0,0 +1,381 @@ + +# Base44 backend functions: Backend functions (mobile apps) + +When a user asks you to integrate with an external API that has no built-in integration, you can write custom backend functions to integrate with the API. +The backend functions are basically an HTTP handler that you can define in `base44/functions/{functionName}/entry.ts`. +It has to export a fetch function that will be called with the incoming request. Logic needed by more than one function must live in a `base44/shared/` module they both import — extract it, never copy it between functions. + +The Core-integration routing rule is an exception: use a backend function for Core methods except `UploadPublicFile`, `UploadPrivateFile` and `CreateFileSignedUrl` (`UploadFile` is the retired spelling of `UploadPublicFile`). + +For external webhooks, browser GET links and OAuth callbacks, use the server-set `X-Base44-App-Url` origin plus `/functions/`. Never expose deployment URLs. Preserve callback query parameters and validate OAuth state in the handler. + + +- Narrow exception to the authentication/service-role rules: public checkout initiation may run without login, including a server-side payment-provider call and creation of a new pending order. Do not report missing authentication solely for that flow when the server selects the payable products, prices and currency from server configuration, provider-owned prices, or catalog records buyers cannot create, update or delete, validates quantities, returns only the new checkout's details, and neither exposes nor changes existing customer/order/entitlement records chosen by the caller. Reading the public catalog through service role does not invalidate this exception. This does not exempt other anonymous writes, messages, paid AI calls or privileged payment operations. +- Report concrete payment trust-boundary failures: caller-controlled amounts/prices/currency or payment status, access to another customer's records, or fulfillment/pass/access granted before server-side payment-provider verification. A success redirect, a client-supplied session/order id, or the presence of a signature header alone is not proof of payment. +- When fixing payment findings, preserve intended guest checkout and its successful response shape. Fix pricing, record scoping and payment verification at the relevant boundary; do not require login or a browser-embedded shared secret merely to start checkout. +- Origin/Referer/Host headers are not caller authentication or proof of payment. Do not add an Origin-equals-Host restriction as a security fix: legitimate custom-domain, platform-domain and preview callers may use different API hosts. Preserve platform app/Host binding and legitimate redirect/CORS allowlists; check both custom-domain and platform-domain guest checkout after changes. + + + +**Validation Flow**: After you generate the function code, it automatically goes through lint validation before deployment. + +**What gets REJECTED** (deployment fails): +- Syntax errors, missing imports, unmatched brackets/braces +- Wrong function structure or return types + +**What PASSES** (deployment succeeds): +- Unused variables, `let` instead of `const`, unused catch parameters +- These "style" issues are ignored to prevent deployment loops + + +- must use `export default async function(req) { ... }` handler - no other patterns accepted +- must declare variables before using them (use `const`, `let`, or `var`) +- must not redeclare variables already in scope (e.g. `user`, `req`) +- must prefix **external-dependency** imports with `npm:package@version` (relative imports of your own files are not external deps) +- must return Response objects - never return strings or plain objects directly +- must return 4xx for caller errors before the work, never `throw` them into the catch: 400 bad/missing input (an unparseable body too: `await req.json().catch(() => ({}))`), 401/403 auth, 404 missing record; SDK calls reject instead of returning null (`auth.me()` signed out: 401, `entities.X.get(id)` on a missing id: 404): before the `if (!x)` guard, turn only that status into null (`.catch((e) => (e.status === 404 ? null : Promise.reject(e)))`), or the guard never runs and the catch returns 500; a 429 or outage still rejects; try/catch the rest — the catch's 500 means the function crashed +- must validate user authentication with `base44.auth.me()` when using Base44 SDK +- Exception: use `base44.asServiceRole.integrations.Core` for Core-integration functions. Keep the function narrowly scoped: validate and bound its input, return only the needed result, and do not use the service role for entities or other app data. +- must use proper JavaScript/TypeScript syntax with matched brackets and braces +- must validate all regex patterns are syntactically correct before writing +- must not throw or `await` at module top-level — keep all init inside the handler (top-level failures cause boot errors, not 500s) +- must `await` every Base44 SDK call (`base44.entities.X.list()`, `base44.auth.me()`, `base44.functions.invoke(...)` all return Promises) +- **Shared modules** - never duplicate logic across functions; extract it to a `base44/shared/` module, e.g. `import { calc } from "../../shared/calc.ts"`; own-dir helpers import as `./util.ts`. +- **Never cross the boundary** - `base44/` is server-side and never in the client bundle: never import it from a frontend file (the screen calls `base44.functions.invoke(...)` instead), and never import `src/` or `@/` from a function. + + + + + +1. CRITICAL: before writing any function, if you need to use a secret / api key, make sure to check if it's already set. If it is missing, ask the user to add it on the app's Dashboard → Secrets page. +2. ADMIN-ONLY FUNCTIONS: If a function should only be invoked by admin (e.g., scheduled tasks, functions a workflow or automation calls, admin dashboard operations, system maintenance), it MUST verify the authenticated user is an admin by checking `user.role === 'admin'` and return 403 Forbidden if not. +3. + +If the secrets you need are not set, ask the user to add them on the app's Dashboard → Secrets page (shown once backend functions are on) and wait for their confirmation before writing the function code. + +A function that reads an unset secret or API key fails at runtime. + +For example, if the user asks for a WeatherAPI.com integration, you should request the WEATHER_API_KEY secret. + +**App Connectors Overview** +When a user asks for an integration to an external service, there is a capability called **app connectors** which allows users to connect to specific services using an OAuth flow with requested scopes. The supported connectors are defined in the specific app connectors coding instructions. + +**Integration Priority:** +When the user needs to integrate an external service, you MUST prioritize methods in the following order: +1. **App Connectors**: If the integration is supported by app connectors, you MUST prioritize it over all other methods. App connectors provide secure OAuth-based authentication with specific scopes. + +2. **Backend Functions with Secrets**: If app connectors are not available, use backend functions with API keys/secrets. + +**Important**: Always check if the requested integration is supported by app connectors first. If it is supported, use app connectors exclusively. Only fall back to other methods if the integration is not supported by app connectors. + +For example, if the user wants to integrate Google Calendar and it's supported by app connectors, always use the app connector OAuth integration. If app connectors support it but backend functions are not enabled, ask the user to enable them first, then proceed with the app connector authorization flow. + + + +**CRITICAL: Platform Version Differences** + +The way functions are called from the frontend depends on your app's platform version: + +- **Your app is Platform V3+**: Use `await base44.functions.invoke('functionName', params)` +- Import: `import { base44 } from "@/api/base44Client";` + +This affects how you reference the function in your examples and documentation. + + + + +THE ONLY WAY TO USE A FUNCTION in a page / component / layout is to use it from the base44 sdk. +the response of the function will be an axios response object. + +for example: +import { base44 } from "@/api/base44Client"; + +... +const response = await base44.functions.invoke('someFunction', {someParam: "someValue"}) +... + +The response is an Axios response object with structure: `{data, status, headers}` - your function's returned data is in `response.data`. + + + + +1. Prefer Web-standard APIs over external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws) +2. Import every external dependency with an `npm:` specifier, e.g. `import Stripe from "npm:stripe@18.5.0"`. +3. Pin a version on every external import. For the Base44 SDK use exactly `npm:@base44/sdk@0.8.54`. +4. Already have an `esm.sh` or `unpkg.com` URL? Replace the CDN hostname with the `npm:` specifier. +5. Export a default request handler: `export default async function(req) { ... }`. +6. Secrets: `import { secrets } from "base44:runtime"` → `secrets.get("NAME")`; pre-populated: BASE44_APP_ID. Users manage secrets on the Dashboard "Secrets" page (its own sidebar item, shown once backend functions are on), not in Settings. +7. NEVER ask users for BASE44_SERVICE_TOKEN, BASE44_SERVICE_ROLE_KEY, or similar - these secrets don't exist. Use `createClientFromRequest(req)` for auth, and `base44.asServiceRole` for service role level operations. +8. Naming: USE ONLY camelCase for function names. No spaces, no /, no hyphens. + **REMEMBER**: Frontend usage differs by platform version - see platform_version_note above. +9. Use `base44:runtime` for secrets (`secrets.get`) and post-response work (`waitUntil`). For work to complete after sending a response: `import { waitUntil } from "base44:runtime"; waitUntil(myAsyncOp())`. + +10. Write files using javascript. No need to use typescript. +11. Don't use multiple paths, only / is allowed when making an API call. +12. Parameters can only be passed through the payload. URL parameters are not allowed and won't work. +13. CRITICAL: For stripe webhooks make sure to do base44 auth after setting the token from request.headers before ANY stripe signature validation. +14. WEB CRYPTO IS ASYNC: The runtime uses the Web Crypto API (SubtleCrypto) which is asynchronous, unlike Node.js's synchronous crypto. For Stripe webhook signature verification, you MUST use `await stripe.webhooks.constructEventAsync(body, signature, secret)` instead of `constructEvent()`. The synchronous version will throw "SubtleCryptoProvider cannot be used in a synchronous context". +15. +16. Base44 SDK usage (service role + request-based init): + 16.a Always import the SDK as: `import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54';` + 16.b Create the client from the incoming request: + +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + const user = await base44.auth.me().catch((e) => (e.status === 401 ? null : Promise.reject(e))); + + if (!user) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // User-scoped operations (use app-user token) + const items = await base44.entities.SampleData.list(); + + // Service-scoped operations (admin privileges) + const allItems = await base44.asServiceRole.entities.SampleData.list(); + + return Response.json({ items, allItems }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} + + + 16.c When to use service role vs user auth: + * Prefer user-scoped calls (`base44.entities/...`) when responding to frontend/user actions. + * Use service role (`base44.asServiceRole`) for Core-integration functions; otherwise only when necessary for admin-level operations and AFTER verifying: + * For entities, functions, and non-Core integrations, there is an authenticated user (base44.auth.isAuthenticated() is true), and + * The business logic allows elevating privileges for this action. + * For webhooks or endpoints intended to be called without user authentication: + * Use the service role to interact with Base44. + * FIRST validate the request authenticity: + * If it's a webhook from a known provider, use its official signature validation or npm package. + * Otherwise, suggest adding a shared secret as a query param when registering the webhook and verify it on each call. + * With `base44.asServiceRole` you can use the following methods: + * `base44.asServiceRole.entities` - For working with entities as admin. + * `base44.asServiceRole.functions` - Call backend functions. + * `base44.asServiceRole.integrations` - Call integrations. + 16.d Calling other backend functions: + * You can invoke another backend function via the SDK: + +const res = await base44.functions.invoke('myFunction', { foo: 'bar' }); + +17. **SHARED CODE**: Never duplicate logic across functions — put it in a `base44/shared/` module and import it (`import { x } from "../../shared/x.ts"`); own-dir helpers import as `./util.ts`. +18. Never load full datasets into memory — paginate entity queries, stream large external responses. +19. **Fast requests**: requests are cut at 5min and a Worker holds 6 connections at once. Run independent calls in `Promise.allSettled` batches of ~6, never one await per item; one `filter({ id: { $in: ids } })` instead of a get per id; every outbound `fetch` takes `signal: AbortSignal.timeout(ms)` sized to the API (~10s, more for LLM/media); on 429 back off or fail, never a tight retry loop. +20. Do NOT use `eval()` or `new Function()`. +21. Do NOT instantiate WebAssembly from bytes constructed at runtime. + + + +**CRITICAL: Admin-Only Function Authentication** + +When creating backend functions that should ONLY be invoked by admin (e.g., scheduled tasks, functions a workflow or automation calls, admin dashboard operations, system maintenance), the function MUST: + +1. **Authenticate the user** using `base44.auth.me()` +2. **Verify the user is an admin** by checking `user.role === 'admin'` +3. **Return 403 Forbidden** if the user is not an admin + + +const base44 = createClientFromRequest(req); +const user = await base44.auth.me().catch((e) => (e.status === 401 ? null : Promise.reject(e))); + +if (user?.role !== 'admin') { return Response.json({ error: 'Forbidden: Admin access required' }, { status: 403 }); } + +// Proceed with admin-only operations... + + +This prevents non-admin users from invoking sensitive operations by directly calling the function endpoint. + + + + + +Use backend functions only when the user asks you to integrate with an external API that has no built-in integration. +Always prefer to use the built-in integartions, as described in integrations, over using backend functions. + + + + + + +Please integrate OpenAI's gpt-4o-mini model to the app, using a backend function. +I want you to tell a joke to the user based on their name. + + + +Before writing such a function, I will need your help to set the OPENAI_API_KEY secret. + +[Assistant requests the OPENAI_API_KEY secret with description: "Please provide your OpenAI API key. You can get it from https://platform.openai.com/api-keys by creating an account and generating a new API key."] + + + +I have now set the OPENAI_API_KEY secret. +Please write the backend function. + + + +Sure, let me create the function for you. + +[Assistant creates base44/functions/openai/entry.ts with the following content:] + +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; +import OpenAI from 'npm:openai'; +import { secrets } from 'base44:runtime'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + + const user = await base44.auth.me().catch((e) => (e.status === 401 ? null : Promise.reject(e))); + if (!user) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Construct SDK clients inside the handler — module-level init throws on missing + // secrets and crashes boot (no try/catch reached, no logs from app code). + const openai = new OpenAI({ apiKey: secrets.get("OPENAI_API_KEY") }); + + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: "You are a witty comedian specializing in clean, clever jokes." + }, + { + role: "user", + content: 'write a joke about the user named ' + user.full_name + } + ] + }); + + return Response.json({ joke: response.choices[0].message.content }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} + +I've created a backend function that uses OpenAI to generate personalized jokes. + + + + + + + +Please add the ability to export all tasks to a PDF file. + + + +I'll add a PDF export feature for your tasks. + +[Assistant creates base44/functions/exportTasks/entry.ts:] +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; +import { jsPDF } from 'npm:jspdf@4.0.0'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + const user = await base44.auth.me().catch((e) => (e.status === 401 ? null : Promise.reject(e))); + + if (!user) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + const tasks = await base44.entities.Task.filter({}); + const doc = new jsPDF(); + + // Title and headers + doc.setFontSize(20); + doc.text('Tasks Report', 20, 20); + + doc.setFontSize(10); + doc.text(`Generated: ${new Date().toLocaleDateString()}`, 20, 30); + + // Table headers + doc.setFontSize(12); + doc.text('Task', 20, 45); + doc.text('Status', 100, 45); + doc.text('Priority', 140, 45); + + // Task content + let y = 55; + tasks.forEach((task) => { + if (y > 270) { // New page if needed + doc.addPage(); + y = 20; + } + + doc.setFontSize(10); + doc.text(task.title, 20, y); + doc.text(task.status, 100, y); + doc.text(task.priority || 'normal', 140, y); + y += 10; + }); + + const pdfBytes = doc.output('arraybuffer'); + + return new Response(pdfBytes, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'attachment; filename=tasks.pdf' + } + }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} + +[Assistant then edits the tasks screen: imports `base44` from `@/api/base44Client` and the `Download` icon, and adds an "Export PDF" button whose `handleExport` calls `base44.functions.invoke('exportTasks')`.] + +Added a PDF export button that generates a beautifully formatted tasks report. + + + + + +If the user wants more information about the function, test the input / output of the function, +use it from an API or as a webhook - +Tell them to go to dashboard -> code -> functions -> function_name. + +That's also where they'll find the url of the function endpoint. +(The frontend should only use the function from the SDK). + +If an in-app agent should use a backend function you created, edited, or reused, add it to that agent's `tool_configs`. + + + +A function deploys automatically once its file is written. Test it by invoking it — `base44.functions.invoke('myFunction', {param: "value"})` from the app, or its endpoint URL from dashboard → Code → Functions — and read its logs there (the `base44-troubleshooter` skill covers log access); logs catch logical errors even on success. + +**Typical workflow:** +1. Write the function code (automatically deploys) +2. Invoke it with a representative payload +3. Check the response and logs for issues +4. Fix the code if needed (automatically redeploys) +5. Re-test to confirm + + +Model values for base44.integrations.Core.InvokeLLM in the generated app: + +The SDK reference includes compatibility aliases. Use this catalog for current model IDs and capability constraints. + +Omit model (or use "automatic") unless the app needs an explicit model. Automatic selection supports text, uploaded files, and web search. + +| model | file_urls | add_context_from_internet | +|---|---|---| +| gemini_3_8_flash | yes | yes | +| gpt_6_luna | yes | no | +| claude-sonnet-5 | yes | no | +| claude_opus_5_5 | yes | no | +| gpt_6_sol | yes | no | +| gpt_6_astra | yes | no | +| claude_fable_5_1 | yes | no | +| glm_5_2 | no | no | + +An incompatible explicit model and capability combination raises an error. Non-default models use more integration credits; tell the user when choosing one. diff --git a/skills/base44-platform-backend-functions/references/web-spa.md b/skills/base44-platform-backend-functions/references/web-spa.md new file mode 100644 index 0000000..ac3a56c --- /dev/null +++ b/skills/base44-platform-backend-functions/references/web-spa.md @@ -0,0 +1,220 @@ + +# Base44 backend functions: Backend functions (Vite SPA) + + +Backend functions: HTTP handlers in `base44/functions/{functionName}/entry.ts`, for external APIs with no built-in integration. Always prefer built-in integrations when they cover the need. Logic needed by more than one function must live in a `base44/shared/` module they both import — extract it, never copy it between functions. + + +The Core-integration routing rule is an exception: use a backend function for Core methods except `UploadPublicFile`, `UploadPrivateFile` and `CreateFileSignedUrl` (`UploadFile` is the retired spelling of `UploadPublicFile`). + + +For external-service integrations, in order: +1. App connectors — when a connector supports the service, use it exclusively (e.g. Google Calendar via its connector). + +2. Backend functions with secrets — when connectors don't cover the service, API keys/secrets. + + + + +- Narrow exception to the authentication/service-role rules: public checkout initiation may run without login, including a server-side payment-provider call and creation of a new pending order. Do not report missing authentication solely for that flow when the server selects the payable products, prices and currency from server configuration, provider-owned prices, or catalog records buyers cannot create, update or delete, validates quantities, returns only the new checkout's details, and neither exposes nor changes existing customer/order/entitlement records chosen by the caller. Reading the public catalog through service role does not invalidate this exception. This does not exempt other anonymous writes, messages, paid AI calls or privileged payment operations. +- Report concrete payment trust-boundary failures: caller-controlled amounts/prices/currency or payment status, access to another customer's records, or fulfillment/pass/access granted before server-side payment-provider verification. A success redirect, a client-supplied session/order id, or the presence of a signature header alone is not proof of payment. +- When fixing payment findings, preserve intended guest checkout and its successful response shape. Fix pricing, record scoping and payment verification at the relevant boundary; do not require login or a browser-embedded shared secret merely to start checkout. +- Origin/Referer/Host headers are not caller authentication or proof of payment. Do not add an Origin-equals-Host restriction as a security fix: legitimate custom-domain, platform-domain and preview callers may use different API hosts. Preserve platform app/Host binding and legitimate redirect/CORS allowlists; check both custom-domain and platform-domain guest checkout after changes. + + + +Before writing a function that needs a secret/API key, check it's set — using an unset secret fails. If missing, ask the user to add it on the app's Dashboard → Secrets page (shown once backend functions are on) and wait for their confirmation before writing code that reads it. +Secrets: `import { secrets } from "base44:runtime"` → `secrets.get("NAME")`; pre-populated: BASE44_APP_ID. Users manage secrets on the Dashboard "Secrets" page (its own sidebar item, shown once backend functions are on), not in Settings. +Never ask for BASE44_SERVICE_TOKEN, BASE44_SERVICE_ROLE_KEY, or similar — those secrets don't exist: auth comes from `createClientFromRequest(req)`, service-role from `base44.asServiceRole`. + + + +Code passes lint validation before deployment. +Rejected: syntax errors, missing imports, unmatched brackets/braces, wrong function structure or return types. +Accepted (to prevent deployment loops): unused variables, `let` vs `const`, unused catch params. + +Requirements: +- Handler: `export default async function(req: Request): Promise { ... }` — the only accepted pattern. +- Return Response objects, not strings/plain objects. +- All initialization inside the handler — module-top-level `throw`/`await` = boot error instead of a 500: no try/catch reached, no app logs. +- Caller errors return 4xx before the work, never `throw` into the catch: 400 bad/missing input (an unparseable body too: `await req.json().catch(() => ({}))`), 401/403 auth, 404 missing record. SDK calls reject instead of returning null (`auth.me()` signed out: 401, `entities.X.get(id)` on a missing id: 404): before the `if (!x)` guard, turn only that status into null (`.catch((e) => (e.status === 404 ? null : Promise.reject(e)))`), or the guard never runs and the catch returns 500; a 429 or outage still rejects. try/catch the rest; the catch's 500 means the function crashed. +- `await` every Base44 SDK call — `base44.entities.X.list()`, `base44.auth.me()`, `base44.functions.invoke(...)` all return Promises. +- Validate user auth with `base44.auth.me()` when using the SDK. +- Exception: use `base44.asServiceRole.integrations.Core` for Core-integration functions. Keep the function narrowly scoped: validate and bound its input, return only the needed result, and do not use the service role for entities or other app data. +- Declare variables before use; don't redeclare in-scope names (`user`, `req`); matched brackets; syntactically valid regex. +- Never duplicate logic across functions — extract it to a `base44/shared/` module and import it (`import { calc } from "../../shared/calc.ts"`); own-dir helpers import as `./util.ts`. +- `base44/` is server-side and never in the client bundle: never import it from a frontend file (the page calls `base44.functions.invoke(...)` instead), and never import `src/` or `@/` from a function. + + + +For external webhooks, browser GET links and OAuth callbacks, use the server-set `X-Base44-App-Url` origin plus `/functions/`. Never expose deployment URLs. Preserve callback query parameters and validate OAuth state in the handler. +- Web-standard APIs over external deps (fetch not Axios, WebSockets API not node-ws). +- External deps: import with a versioned `npm:` specifier (Base44 SDK: `npm:@base44/sdk@0.8.54`); rewrite `esm.sh` / `unpkg.com` URLs to `npm:`. +- JavaScript, not TypeScript. Names camelCase only: no spaces, `/`, hyphens. +- Use `base44:runtime` for secrets and post-response work (see below); no `eval()`/`new Function()`; no WebAssembly from runtime-built bytes. +- Paginate entity queries, stream large responses — don't load full datasets into memory. +- Requests are cut at 5min and a Worker holds 6 connections at once: run independent calls in `Promise.allSettled` batches of ~6, never one await per item; one `filter({ id: { $in: ids } })` instead of a get per id; every outbound `fetch` takes `signal: AbortSignal.timeout(ms)` sized to the API (~10s, more for LLM/media); on 429 back off or fail, never a tight retry loop. + + + +For work to complete after sending a response (analytics pings, non-critical logging): import `waitUntil` from `base44:runtime` and call `waitUntil(myAsyncOp())`. The function returns the response immediately; the Worker stays alive until the promise settles. +```js +import { waitUntil } from "base44:runtime"; +export default async function(req) { + waitUntil(fetch("https://hooks.example.com/ping", { method: "POST" })); + return Response.json({ ok: true }); +} +``` + + + + +```js +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + const user = await base44.auth.me().catch((e) => (e.status === 401 ? null : Promise.reject(e))); + if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); + const { id } = await req.json().catch(() => ({})); + if (!id) return Response.json({ error: 'id is required' }, { status: 400 }); + const record = await base44.entities.SampleData.get(id).catch((e) => (e.status === 404 ? null : Promise.reject(e))); + if (!record) return Response.json({ error: 'Not found' }, { status: 404 }); + const { items } = await base44.entities.SampleData.filter({ status: 'open' }, { sort: '-created_date', limit: 50 }); // user-scoped (app-user token), one page + const total = await base44.asServiceRole.entities.SampleData.count(); // service-scoped (admin), a number: no records read + const res = await base44.functions.invoke('myFunction', { foo: 'bar' }); // another backend function + return Response.json({ record, items, total }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} +``` + +Prefer user-scoped calls (`base44.entities...`) for frontend/user actions. Use `base44.asServiceRole.integrations.Core` for Core-integration functions. For entities, functions, and non-Core integrations, use `base44.asServiceRole` only when admin-level operations are necessary, after verifying `base44.auth.isAuthenticated()` is true and the business logic allows the elevation. One exception: a deliberately public lookup, such as a visitor checking their own submission, may skip the sign-in check when a long random code (`crypto.randomUUID()`) saved at submission is the check; reject a missing or blank code, match it exactly, and return only the fields needed. + + + +Endpoints called without user auth (webhooks): use the service role; validate request authenticity first — anyone can reach the endpoint. Known providers: official signature validation/npm package; otherwise a shared secret as a query param, verified each call. +Stripe: do the base44 auth — after setting the token from request.headers — before any Stripe signature validation. Web Crypto is async (SubtleCrypto): `await stripe.webhooks.constructEventAsync(body, signature, secret)`; synchronous `constructEvent()` throws "SubtleCryptoProvider cannot be used in a synchronous context". + + + +Admin-only functions (scheduled tasks, functions a workflow or automation calls, admin dashboard ops, maintenance) MUST authenticate via `base44.auth.me()`, verify `user.role === 'admin'`, and return 403 Forbidden otherwise — this stops non-admins from invoking the endpoint directly. + + + + +Platform V3+: pages/components/layouts use functions only through the SDK: + +```js +import { base44 } from "@/api/base44Client"; +const response = await base44.functions.invoke('someFunction', {someParam: "someValue"}) +``` + +Response: Axios response object `{data, status, headers}` — your function's data is in `response.data`. + +Parameters only through the payload — URL parameters won't work. Single path only (`/`, no multiple paths). + + + +Function details, input/output testing, API/webhook usage, endpoint URL: dashboard -> code -> functions -> function_name. + + + +A function deploys automatically once its file is written. Test it by invoking it — `base44.functions.invoke('myFunction', {param: "value"})` from the app, or its endpoint URL from dashboard → Code → Functions — and read its logs there (the `base44-troubleshooter` skill covers log access); logs catch logical errors even on success. Write (auto-deploys) → invoke → check response/logs → fix (auto-redeploys) → re-test. + + + + +Integrate OpenAI's gpt-4o-mini via a backend function: tell the user a joke based on their name. + + +I need the OPENAI_API_KEY secret first — please add it on the app's Dashboard → Secrets page. + + +I set the OPENAI_API_KEY secret. Please write the function. + + +``` +// base44/functions/openai/entry.ts +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; +import OpenAI from 'npm:openai'; +import { secrets } from 'base44:runtime'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + const user = await base44.auth.me().catch((e) => (e.status === 401 ? null : Promise.reject(e))); + if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); + // SDK clients constructed inside the handler — module-level init throws on missing secrets and crashes boot. + const openai = new OpenAI({ apiKey: secrets.get("OPENAI_API_KEY") }); + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: 'write a clean, clever joke about the user named ' + user.full_name }] + }); + return Response.json({ joke: response.choices[0].message.content }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} + +``` + +Created a backend function that generates personalized jokes. + + + + + +Add the ability to export all tasks to a PDF file. + + +``` +// base44/functions/exportTasks/entry.ts +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; +import { jsPDF } from 'npm:jspdf@4.0.0'; + +export default async function(req) { + try { + // ...createClientFromRequest(req) + auth.me() + 401 guard, as in base44_sdk_usage... + const tasks = await base44.entities.Task.filter({}); + const doc = new jsPDF(); + // ...doc.text title/headers + one row per task; doc.addPage() when y > 270... + const pdfBytes = doc.output('arraybuffer'); + return new Response(pdfBytes, { + status: 200, + headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename=tasks.pdf' } + }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} +``` + +Then in `src/pages/Tasks.jsx`: import `base44` from `@/api/base44Client` and the `Download` icon, and add an "Export PDF" button whose `handleExport` calls `base44.functions.invoke('exportTasks')` and downloads the response blob. + +Added a PDF export button. + + + +Model values for base44.integrations.Core.InvokeLLM in the generated app: + +The SDK reference includes compatibility aliases. Use this catalog for current model IDs and capability constraints. + +Omit model (or use "automatic") unless the app needs an explicit model. Automatic selection supports text, uploaded files, and web search. + +| model | file_urls | add_context_from_internet | +|---|---|---| +| gemini_3_8_flash | yes | yes | +| gpt_6_luna | yes | no | +| claude-sonnet-5 | yes | no | +| claude_opus_5_5 | yes | no | +| gpt_6_sol | yes | no | +| gpt_6_astra | yes | no | +| claude_fable_5_1 | yes | no | +| glm_5_2 | no | no | + +An incompatible explicit model and capability combination raises an error. Non-default models use more integration credits; tell the user when choosing one. diff --git a/skills/base44-platform-backend-functions/references/web-tanstack.md b/skills/base44-platform-backend-functions/references/web-tanstack.md new file mode 100644 index 0000000..5650bb4 --- /dev/null +++ b/skills/base44-platform-backend-functions/references/web-tanstack.md @@ -0,0 +1,39 @@ + +# Base44 backend functions: Server functions and server routes (TanStack Start) + + +All server-side code is a TanStack Start server function or server route under `src/`, run by Base44 on Cloudflare Workers. Prefer built-in integrations and connectors when they cover the need. Core integrations that read a user's files or spend credits (`InvokeLLM`, `ExtractDataFromUploadedFile`, transcription, image generation) run in a server function: upload from the browser, then pass the `file_url`/`file_uri` to it — never parse PDFs, images or spreadsheets in the browser. + + + +- Server function — `createServerFn({ method })` in `src/lib/server-fns.js` (or another module a route imports; server-only helpers go in `*.server.js` files, kept out of the browser bundle). The default for anything a page needs from the server: loader data, writes, calls that need a secret. An RPC called from a `loader` (`loader: () => listOrders()`) or a component (`useServerFn(createOrder)` + a TanStack Query mutation); `GET` reads, `POST` writes; validate with zod `.validator(z.object({ … }))` and call as `fn({ data: { … } })`: only `data` reaches the handler, so pass one object (an update is `fn({ data: { id, ...patch } })`), never a bare id. +- Server route — `src/routes/api/.js`: `createFileRoute("/api/")({ server: { handlers: { GET: async ({ request, params, context }) => Response.json(...) } } })`. A raw HTTP endpoint, only for a URL something other than this app's own code requests: webhooks and callbacks, a link or image the browser loads by itself (``, ``), feeds and public APIs, tools an in-app agent or the app's MCP server calls. The app's own code never fetches its own routes — streamed AI replies and third-party calls are server functions. Never define a route under `/api/apps`, `/api/app-logs` or `/ws-user-apps` — shadowing the Base44 platform API breaks the app's data layer. +- An AI chat or agent loop (streamed replies, the model calling tools) is a server function running TanStack AI on the app's AI gateway, with no API key: see the `base44-platform-agents` skill for the pattern before writing one, rather than streaming InvokeLLM output by hand. +- Logic two handlers share lives in a plain module both import — never one calling the other over HTTP. + + + +Every server function and server route handler receives `context.getBase44()` (wired in `src/start.js`): a Base44 SDK client acting as the visitor — anonymous when nobody is signed in — so entity access rules apply as in the browser. `context.getBase44().asServiceRole` bypasses them: use it only when the handler must see more than its caller, and never return more than the caller should see. Entities, users, connectors (`getConnection`) and `integrations.Core.*` use the browser SDK's API; auth comes from the platform headers, so nothing needs configuring. Call `getBase44()` only in handlers that need Base44 — a handler that never calls it must still answer without those headers. +A failed SDK call rejects with an error carrying the HTTP `status`; `entities..get(id)` rejects with 404 when the record is missing or hidden by the caller's access rules. When a route should show not-found, return null from the server function for that 404 and `throw notFound()` in the loader; rethrow every other error. + + + +Server functions and server routes are public endpoints: anyone can call them, with any input. Before `asServiceRole` or credit-spending work (Core integrations such as InvokeLLM), verify the caller and that the business logic allows it: `.middleware([requireUser])` from `@/lib/auth-middleware` (`createServerFn({ method: "POST" }).middleware([requireUser]).handler(({ context }) => …)`) rejects anonymous callers with 401 and sets `context.user`; a server route whose callers send a Base44 token uses `server: { middleware: [requireUserRoute], handlers }` from the same module, the same way (only on `src/routes/api/*` files — a route's middleware also runs for every route under it). Links and images the browser loads by itself carry no token, so a route serving them is public or checks a short-lived signed token a server function issued. Admin-only handlers also require `context.user.role === "admin"` and reject anyone else with 403. Skip the sign-in check only for a deliberately public feature (a no-login app whose visitors are meant to trigger it), and bound its input: `.max()` on every string and array, `z.enum` for choices. Webhooks verify the request's signature or shared secret instead. + + + +- Narrow exception to the authentication/service-role rules: public checkout initiation may run without login, including a server-side payment-provider call and creation of a new pending order. Do not report missing authentication solely for that flow when the server selects the payable products, prices and currency from server configuration, provider-owned prices, or catalog records buyers cannot create, update or delete, validates quantities, returns only the new checkout's details, and neither exposes nor changes existing customer/order/entitlement records chosen by the caller. Reading the public catalog through service role does not invalidate this exception. This does not exempt other anonymous writes, messages, paid AI calls or privileged payment operations. +- Report concrete payment trust-boundary failures: caller-controlled amounts/prices/currency or payment status, access to another customer's records, or fulfillment/pass/access granted before server-side payment-provider verification. A success redirect, a client-supplied session/order id, or the presence of a signature header alone is not proof of payment. +- When fixing payment findings, preserve intended guest checkout and its successful response shape. Fix pricing, record scoping and payment verification at the relevant boundary; do not require login or a browser-embedded shared secret merely to start checkout. +- Origin/Referer/Host headers are not caller authentication or proof of payment. Do not add an Origin-equals-Host restriction as a security fix: legitimate custom-domain, platform-domain and preview callers may use different API hosts. Preserve platform app/Host binding and legitimate redirect/CORS allowlists; check both custom-domain and platform-domain guest checkout after changes. + + + +Before writing server code that needs an API key or secret, check it is set — an unset secret fails. If missing, ask the user to add it on the app's Dashboard → Secrets page and wait for their confirmation before writing code that reads it. Read it with `import { secrets } from "base44:runtime"` → `secrets.get("NAME")` (`undefined` if unset) only in server functions, server routes and `*.server.js` files; never `process.env` or a `VITE_*` var (build-time, client-visible). A set or rotated secret reaches the dev preview and the latest commit's worker within seconds — no redeploy; a published app behind the latest commit picks it up on its next publish. + + + +Server code runs on Cloudflare Workers with `nodejs_compat`: most Node built-ins work, but there is no filesystem, no state shared between requests (module-level caches reset at any time), and no timer that outlives a response — `waitUntil` from `base44:runtime` keeps post-response work alive. Web-standard APIs over dependencies (`fetch`, not axios). `console.log` from server code is not shown to the user anywhere: while debugging, return the diagnostic in the response. + diff --git a/skills/base44-platform-connectors/SKILL.md b/skills/base44-platform-connectors/SKILL.md new file mode 100644 index 0000000..aeaa20a --- /dev/null +++ b/skills/base44-platform-connectors/SKILL.md @@ -0,0 +1,26 @@ +--- +name: base44-platform-connectors +description: "Base44 app connectors: OAuth integrations with external services (Google, Slack, GitHub and more) in shared, app-user and BYO-shared modes, how to connect one from an external agent, and how server code reads the token with getConnection. Use when the app must act on a user's or the builder's account at a third-party service." +--- + +# Base44 connectors + +Connectors are configured against the app, not authored as files: list the catalog, connect with the complete scope set, then read tokens in server code. + +> **Requires the Builder plan or above.** The workspace's entitlement is reported by the agent-profile endpoint; on a lower plan the platform rejects the deploy. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [App connectors (Vite SPA)](references/web-spa.md) | +| Web app, TanStack Start | [App connectors (TanStack Start)](references/web-tanstack.md) | +| Native mobile app (Expo, React Native) | [App connectors (mobile apps)](references/mobile.md) | diff --git a/skills/base44-platform-connectors/references/mobile.md b/skills/base44-platform-connectors/references/mobile.md new file mode 100644 index 0000000..dd349d0 --- /dev/null +++ b/skills/base44-platform-connectors/references/mobile.md @@ -0,0 +1,204 @@ + +# Base44 connectors: App connectors (mobile apps) + + +App connectors allow you to integrate with external services in an easy way using OAuth. +App connectors come in the following types: + + +1. **Shared connector**: The app builder connects their own account. All app users share the app builder's single connection. Backend functions call the external API on the app builder's behalf. See . +2. **App user connector**: The app builder provides their OAuth app credentials (client ID, client secret). Each App User connects their own account individually. Backend functions call the external API on the app user's behalf using the app user's tokens. See . +3. **BYO shared connector**: A workspace admin registers their own OAuth app (an `OrganizationConnector`). The app builder consents once against it; the resulting token is shared across all app users. Use when the platform doesn't have its own OAuth app for the integration (e.g., Snowflake, Databricks) or when the workspace wants to use its own OAuth app. See . + + +Backend functions MUST be enabled for each connector type. + + +Connecting a connector happens outside the code: read the catalog and each connector's details (description, scopes, auth status, connection mode) with the Base44 MCP `list_connectors` tool or `base44 connectors list-available --app-id `; connect or re-scope a shared connector with `initiate_connector_connection` / `base44 connectors initiate` passing the complete scope set (it replaces, never merges), and let the user finish OAuth in their browser. App-user and BYO-shared connectors are registered by a workspace admin in Workspace settings → Connectors. + + + +The catalog is live: list it with `list_connectors` (MCP) or `base44 connectors list-available`; each entry gives the display name and its `integration_type`. + + +Use the connector's integration_type for getConnection. +Many connectors also support webhooks for real-time event notifications. Once a connector is authorized, you can create a workflow with a connector trigger to subscribe to events (see the `base44-platform-workflows` skill). + + +App connectors require a Builder+ subscription; the app profile reports the workspace's entitlement. + +When the user asks to connect or use an external service: +1. If the catalog has no connector for the service, do not use the connector APIs. Integrate with backend functions with secrets or direct API calls instead. +2. Read the connector's details (description, scopes, auth status, connection mode, usage guide) with the Base44 MCP `list_connectors` tool or `base44 connectors list-available`, and follow its connection mode: shared (one account serves every app user) or app user (each user connects their own). +3. Write connector-backed code only once the connector is authorized, and read its token in server code by connection mode: shared → `base44.asServiceRole.connectors.getConnection(integration_type)`; BYO shared → `base44.asServiceRole.connectors.getWorkspaceConnection(connectorId)` (see ); app user → `base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` for the signed-in app user, after the frontend let them consent with `connectAppUser(connectorId)` (see ). Never serve one app user's data through the shared account's token. Then make the API calls yourself. + + + + +A workspace-registered OAuth app (an `OrganizationConnector`) that the app builder consents once against. The resulting token is shared across all app users — same semantics as a SHARED connector, but the OAuth credentials come from the workspace admin (not the platform) and so the connection is keyed by `connectorId` rather than `integration_type`. + +**Prerequisite — the workspace connector must already exist.** A workspace admin registers it in workspace settings → Connectors ("Manage connectors" for users on the new integrations view) with the OAuth client_id, client_secret and any integration-specific config such as a Snowflake subdomain; its `id` is the `connectorId` below. If none exists for the integration the user needs, tell the user to have their admin register one, then retry. + +The app builder consents once against the connector from the Base44 builder chat (ask it to connect the workspace connector); the MCP `initiate_connector_connection` tool connects platform-shared connectors by integration type only. + +**No frontend code is generated for this mode** — consent is a one-time build-time action, not a runtime action in the published app. Unlike APP_USER, there is no `connectAppUser` call. + +**Backend function — use `getWorkspaceConnection(connectorId)`:** + +``` +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + + // connectorId is the workspace connector's id, e.g. "abc123" + const { accessToken, connectionConfig } = await base44.asServiceRole.connectors.getWorkspaceConnection("abc123"); + + // connectionConfig carries any admin-configured parameters such as + // subdomain, workspace URL, etc. Consult the integration's usage guide + // for the exact field name. + const apiUrl = `https://${connectionConfig.subdomain}.snowflakecomputing.com/api/v2/statements`; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ statement: "SELECT COUNT(*) FROM my_table" }), + }); + const data = await response.json(); + return Response.json(data); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} +``` + +Differences from SHARED code-gen: +- A dedicated `getWorkspaceConnection(connectorId)` method is called with the connector's `id` as a string — NOT `getConnection`, which is reserved for platform-shared connectors keyed by `""`. +- `connectionConfig` is sourced from the workspace admin's `OrganizationConnector`, not per-app — so whatever the admin configured at registration time is what you get at runtime. + + + +App-user connectors are registered per workspace (workspace settings → Connectors); each has an `id`, the `connectorId` below. If one is already registered for that integration type, use its `id` — do not register another. Register a second connector of the same type only when the user asks for a separate one (a different account or OAuth app); when that is unclear, ask which they want instead of registering. + +When none is registered yet, the user registers one there with the integration type, a name no existing connector uses, a description and the minimal scopes the request needs — suggest those from the provider's OAuth documentation. + +When implementing app-user connector functionality: +1. **Connector**: Use the connector already registered for that integration type; the user registers a new one only when none exists. +2. **Backend function**: Reuse an existing backend function when it already provides the requested connector-backed behavior. If no suitable function exists, create or edit one that uses `base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` to get the *current app user's* connection (access token + connection config) and call the external API. +3. **Frontend**: Generate the connect/disconnect UI and data fetching, following the three rules in ``. Use the connector's `id` as the `connectorId`. +4. **In-app agents**: If a specific agent should use this functionality, add the backend function to its `tool_configs`. Give it app-user connector permission only when it should help app users connect or reconnect that account: in the agent's config, `app_user_connector_configs` takes the exact connector `id` as `connector_id`; do not pass the integration type or connector name. + + +**APP_USER mode** — requires BOTH frontend code (to let each app user consent) AND a backend function (to use the current app user's token). + +All APP_USER SDK methods are keyed on **connectorId** (the connector's `id` in the dashboard) — NOT the integration type. + +**SDK methods:** + +| Connector mode | Frontend | Backend function | +|---|---|---| +| SHARED | None (no frontend SDK) | `base44.asServiceRole.connectors.getConnection(type)` | +| BYO_SHARED | None (no frontend SDK — consent is at build time) | `base44.asServiceRole.connectors.getWorkspaceConnection(connectorId)` | +| APP_USER | `base44.connectors.connectAppUser(connectorId)`, `base44.connectors.disconnectAppUser(connectorId)` | `base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` | + +- `connectAppUser(connectorId)` — returns a URL string. Open it so the app user can complete OAuth consent. +- `disconnectAppUser(connectorId)` — revokes the OAuth connection server-side, returns nothing. +- `getCurrentAppUserConnection(connectorId)` — returns a connection object `{ accessToken, connectionConfig }` for the **app user's** OAuth connection (not the builder's). Destructure `accessToken` and optionally `connectionConfig` (e.g. `const { accessToken, connectionConfig } = await ...getCurrentAppUserConnection(connectorId)`). `connectionConfig` may contain connector-specific parameters (e.g. subdomain, workspace URL) — only use it when the connector's usage guide says to. + +**Backend function example:** + +``` +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + const user = await base44.auth.me(); + if (!user) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // connectorId is the registered connector's id, e.g. "abc123" + const { accessToken, connectionConfig } = await base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId); + + const response = await fetch('https://www.googleapis.com/calendar/v3/calendars/primary/events', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + const data = await response.json(); + return Response.json({ events: data.items || [] }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} +``` + + + +**Three rules you MUST follow when generating frontend code that uses app user connectors. Design the page UI however fits the app — but always apply these rules:** + +**Rule 1 — Auth gate:** `connectAppUser()` will fail if the app user is not signed in. Use `base44.auth.isAuthenticated()` to check before rendering any connector UI (Connect button, data that depends on the connector). If not authenticated, show a login button that calls `base44.auth.redirectToLogin()`. + +**Rule 2 — Connection status via data fetch:** There is no separate "check connection" API. Instead, call the backend function that uses the connector on page load inside a try/catch. If it succeeds → user is connected (and you already have the data). If it throws → user is not connected, show the Connect button. Extract this fetch as a reusable function — you will call it again in Rule 3. + +**Rule 3 — Auto-refresh after OAuth:** The OAuth flow opens in a new tab. When the user finishes and the tab closes, the original page doesn't know. To fix this: use `window.open()` to keep a reference to the popup, then poll `popup.closed` — when it closes, call the reusable fetch from Rule 2 to refresh data automatically. + +**How the three rules connect (hooks/handlers skeleton):** +``` +const [user, setUser] = useState(null); +const [data, setData] = useState(null); +const [connected, setConnected] = useState(false); +const [loading, setLoading] = useState(true); + +// Rule 2: reusable fetch — doubles as connection check AND data loader +const fetchData = async () => { + try { + const res = await base44.functions.invoke("myBackendFunction", {}); + setData(res.data); + setConnected(true); + } catch { + setConnected(false); + } +}; + +// Rule 1+2: check auth first, then fetch to detect connection status +useEffect(() => { + base44.auth.isAuthenticated().then(async (authed) => { + if (authed) { + const me = await base44.auth.me(); + setUser(me); + await fetchData(); + } + setLoading(false); + }); +}, []); + +// Rule 3: open OAuth popup, poll for close, then re-fetch +const handleConnect = async () => { + const url = await base44.connectors.connectAppUser(CONNECTOR_ID); + const popup = window.open(url, "_blank"); + const timer = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(timer); + fetchData(); + } + }, 500); +}; + +const handleDisconnect = async () => { + await base44.connectors.disconnectAppUser(CONNECTOR_ID); + setConnected(false); + setData(null); +}; + +// Then in your JSX, use loading, user, and connected to decide what to render. +// If !user, show a login button that calls base44.auth.redirectToLogin(). +// Design the UI however fits the app. +``` + + diff --git a/skills/base44-platform-connectors/references/web-spa.md b/skills/base44-platform-connectors/references/web-spa.md new file mode 100644 index 0000000..4af363f --- /dev/null +++ b/skills/base44-platform-connectors/references/web-spa.md @@ -0,0 +1,201 @@ + +# Base44 connectors: App connectors (Vite SPA) + + +App connectors integrate the app with external services via OAuth. + + +1. Shared: builder connects their own account; all app users share it; backend functions act on the builder's behalf. See . +2. App user: builder provides OAuth app credentials (client ID, client secret); each app user connects their own account; backend functions use that user's tokens. See . +3. BYO shared: workspace admin registers their own OAuth app (`OrganizationConnector`); builder consents once; token shared across all app users. For services with no platform OAuth app (e.g., Snowflake, Databricks) or when the workspace wants its own. See . + + + +Connecting a connector happens outside the code: read the catalog and each connector's details (description, scopes, auth status, connection mode) with the Base44 MCP `list_connectors` tool or `base44 connectors list-available --app-id `; connect or re-scope a shared connector with `initiate_connector_connection` / `base44 connectors initiate` passing the complete scope set (it replaces, never merges), and let the user finish OAuth in their browser. App-user and BYO-shared connectors are registered by a workspace admin in Workspace settings → Connectors. + + + +The catalog is live: list it with `list_connectors` (MCP) or `base44 connectors list-available`; each entry gives the display name and its `integration_type`. + + +Use the connector's integration_type for getConnection. +Many connectors support webhooks: once authorized, create a workflow with a connector trigger (see the `base44-platform-workflows` skill) to route events to handler logic. + + +App connectors require a Builder+ subscription; the app profile reports the workspace's entitlement. + +When the user asks to connect or use an external service: +1. If the catalog has no connector for the service, do not use the connector APIs. Integrate with backend functions with secrets or direct API calls instead. +2. Read the connector's details (description, scopes, auth status, connection mode, usage guide) with the Base44 MCP `list_connectors` tool or `base44 connectors list-available`, and follow its connection mode: shared (one account serves every app user) or app user (each user connects their own). +3. Write connector-backed code only once the connector is authorized, and read its token in server code by connection mode: shared → `base44.asServiceRole.connectors.getConnection(integration_type)`; BYO shared → `base44.asServiceRole.connectors.getWorkspaceConnection(connectorId)` (see ); app user → `base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` for the signed-in app user, after the frontend let them consent with `connectAppUser(connectorId)` (see ). Never serve one app user's data through the shared account's token. Then make the API calls yourself. + + + + +A workspace-registered OAuth app (an `OrganizationConnector`) that the app builder consents once against. The resulting token is shared across all app users — same semantics as a SHARED connector, but the OAuth credentials come from the workspace admin (not the platform) and so the connection is keyed by `connectorId` rather than `integration_type`. + +**Prerequisite — the workspace connector must already exist.** A workspace admin registers it in workspace settings → Connectors ("Manage connectors" for users on the new integrations view) with the OAuth client_id, client_secret and any integration-specific config such as a Snowflake subdomain; its `id` is the `connectorId` below. If none exists for the integration the user needs, tell the user to have their admin register one, then retry. + +The app builder consents once against the connector from the Base44 builder chat (ask it to connect the workspace connector); the MCP `initiate_connector_connection` tool connects platform-shared connectors by integration type only. + +**No frontend code is generated for this mode** — consent is a one-time build-time action, not a runtime action in the published app. Unlike APP_USER, there is no `connectAppUser` call. + +**Backend function — use `getWorkspaceConnection(connectorId)`:** + +``` +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + + // connectorId is the workspace connector's id, e.g. "abc123" + const { accessToken, connectionConfig } = await base44.asServiceRole.connectors.getWorkspaceConnection("abc123"); + + // connectionConfig carries any admin-configured parameters such as + // subdomain, workspace URL, etc. Consult the integration's usage guide + // for the exact field name. + const apiUrl = `https://${connectionConfig.subdomain}.snowflakecomputing.com/api/v2/statements`; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ statement: "SELECT COUNT(*) FROM my_table" }), + }); + const data = await response.json(); + return Response.json(data); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} +``` + +Differences from SHARED code-gen: +- A dedicated `getWorkspaceConnection(connectorId)` method is called with the connector's `id` as a string — NOT `getConnection`, which is reserved for platform-shared connectors keyed by `""`. +- `connectionConfig` is sourced from the workspace admin's `OrganizationConnector`, not per-app — so whatever the admin configured at registration time is what you get at runtime. + + + +App-user connectors are registered per workspace (workspace settings → Connectors); each has an `id`, the `connectorId` below. If one is already registered for that integration type, use its `id` — do not register another. Register a second connector of the same type only when the user asks for a separate one (a different account or OAuth app); when that is unclear, ask which they want instead of registering. + +When none is registered yet, the user registers one there with the integration type, a name no existing connector uses, a description and the minimal scopes the request needs — suggest those from the provider's OAuth documentation. + +When implementing app-user connector functionality: +1. **Connector**: Use the connector already registered for that integration type; the user registers a new one only when none exists. +2. **Backend function**: Reuse an existing backend function when it already provides the requested connector-backed behavior. If no suitable function exists, create or edit one that uses `base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` to get the *current app user's* connection (access token + connection config) and call the external API. +3. **Frontend**: Generate the connect/disconnect UI and data fetching, following the three rules in ``. Use the connector's `id` as the `connectorId`. +4. **In-app agents**: If a specific agent should use this functionality, add the backend function to its `tool_configs`. Give it app-user connector permission only when it should help app users connect or reconnect that account: in the agent's config, `app_user_connector_configs` takes the exact connector `id` as `connector_id`; do not pass the integration type or connector name. + + +**APP_USER mode** — requires BOTH frontend code (to let each app user consent) AND a backend function (to use the current app user's token). + +All APP_USER SDK methods are keyed on **connectorId** (the connector's `id` in the dashboard) — NOT the integration type. + +**SDK methods:** + +| Connector mode | Frontend | Backend function | +|---|---|---| +| SHARED | None (no frontend SDK) | `base44.asServiceRole.connectors.getConnection(type)` | +| BYO_SHARED | None (no frontend SDK — consent is at build time) | `base44.asServiceRole.connectors.getWorkspaceConnection(connectorId)` | +| APP_USER | `base44.connectors.connectAppUser(connectorId)`, `base44.connectors.disconnectAppUser(connectorId)` | `base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` | + +- `connectAppUser(connectorId)` — returns a URL string. Open it so the app user can complete OAuth consent. +- `disconnectAppUser(connectorId)` — revokes the OAuth connection server-side, returns nothing. +- `getCurrentAppUserConnection(connectorId)` — returns a connection object `{ accessToken, connectionConfig }` for the **app user's** OAuth connection (not the builder's). Destructure `accessToken` and optionally `connectionConfig` (e.g. `const { accessToken, connectionConfig } = await ...getCurrentAppUserConnection(connectorId)`). `connectionConfig` may contain connector-specific parameters (e.g. subdomain, workspace URL) — only use it when the connector's usage guide says to. + +**Backend function example:** + +``` +import { createClientFromRequest } from 'npm:@base44/sdk@0.8.54'; + +export default async function(req) { + try { + const base44 = createClientFromRequest(req); + const user = await base44.auth.me(); + if (!user) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // connectorId is the registered connector's id, e.g. "abc123" + const { accessToken, connectionConfig } = await base44.asServiceRole.connectors.getCurrentAppUserConnection(connectorId); + + const response = await fetch('https://www.googleapis.com/calendar/v3/calendars/primary/events', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + const data = await response.json(); + return Response.json({ events: data.items || [] }); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} +``` + + + +**Three rules you MUST follow when generating frontend code that uses app user connectors. Design the page UI however fits the app — but always apply these rules:** + +**Rule 1 — Auth gate:** `connectAppUser()` will fail if the app user is not signed in. Use `base44.auth.isAuthenticated()` to check before rendering any connector UI (Connect button, data that depends on the connector). If not authenticated, show a login button that calls `base44.auth.redirectToLogin()`. + +**Rule 2 — Connection status via data fetch:** There is no separate "check connection" API. Instead, call the backend function that uses the connector on page load inside a try/catch. If it succeeds → user is connected (and you already have the data). If it throws → user is not connected, show the Connect button. Extract this fetch as a reusable function — you will call it again in Rule 3. + +**Rule 3 — Auto-refresh after OAuth:** The OAuth flow opens in a new tab. When the user finishes and the tab closes, the original page doesn't know. To fix this: use `window.open()` to keep a reference to the popup, then poll `popup.closed` — when it closes, call the reusable fetch from Rule 2 to refresh data automatically. + +**How the three rules connect (hooks/handlers skeleton):** +``` +const [user, setUser] = useState(null); +const [data, setData] = useState(null); +const [connected, setConnected] = useState(false); +const [loading, setLoading] = useState(true); + +// Rule 2: reusable fetch — doubles as connection check AND data loader +const fetchData = async () => { + try { + const res = await base44.functions.invoke("myBackendFunction", {}); + setData(res.data); + setConnected(true); + } catch { + setConnected(false); + } +}; + +// Rule 1+2: check auth first, then fetch to detect connection status +useEffect(() => { + base44.auth.isAuthenticated().then(async (authed) => { + if (authed) { + const me = await base44.auth.me(); + setUser(me); + await fetchData(); + } + setLoading(false); + }); +}, []); + +// Rule 3: open OAuth popup, poll for close, then re-fetch +const handleConnect = async () => { + const url = await base44.connectors.connectAppUser(CONNECTOR_ID); + const popup = window.open(url, "_blank"); + const timer = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(timer); + fetchData(); + } + }, 500); +}; + +const handleDisconnect = async () => { + await base44.connectors.disconnectAppUser(CONNECTOR_ID); + setConnected(false); + setData(null); +}; + +// Then in your JSX, use loading, user, and connected to decide what to render. +// If !user, show a login button that calls base44.auth.redirectToLogin(). +// Design the UI however fits the app. +``` + + diff --git a/skills/base44-platform-connectors/references/web-tanstack.md b/skills/base44-platform-connectors/references/web-tanstack.md new file mode 100644 index 0000000..ad7be74 --- /dev/null +++ b/skills/base44-platform-connectors/references/web-tanstack.md @@ -0,0 +1,201 @@ + +# Base44 connectors: App connectors (TanStack Start) + + +App connectors integrate the app with external services via OAuth. + + +1. Shared: builder connects their own account; all app users share it; server code acts on the builder's behalf. See . +2. App user: builder provides OAuth app credentials (client ID, client secret); each app user connects their own account; server code uses that user's tokens. See . +3. BYO shared: workspace admin registers their own OAuth app (`OrganizationConnector`); builder consents once; token shared across all app users. For services with no platform OAuth app (e.g., Snowflake, Databricks) or when the workspace wants its own. See . + + + +Connecting a connector happens outside the code: read the catalog and each connector's details (description, scopes, auth status, connection mode) with the Base44 MCP `list_connectors` tool or `base44 connectors list-available --app-id `; connect or re-scope a shared connector with `initiate_connector_connection` / `base44 connectors initiate` passing the complete scope set (it replaces, never merges), and let the user finish OAuth in their browser. App-user and BYO-shared connectors are registered by a workspace admin in Workspace settings → Connectors. + + + +The catalog is live: list it with `list_connectors` (MCP) or `base44 connectors list-available`; each entry gives the display name and its `integration_type`. + + +Use the connector's integration_type for getConnection. +Many connectors support webhooks: once authorized, create a workflow with a connector trigger whose step calls `invoke_server_route` on a POST server route (see the `base44-platform-workflows` skill). + + +App connectors require a Builder+ subscription; the app profile reports the workspace's entitlement. + +When the user asks to connect or use an external service: +1. If the catalog has no connector for the service, do not use the connector APIs. Integrate with server code with secrets or direct API calls instead. +2. Read the connector's details (description, scopes, auth status, connection mode, usage guide) with the Base44 MCP `list_connectors` tool or `base44 connectors list-available`, and follow its connection mode: shared (one account serves every app user) or app user (each user connects their own). +3. Write connector-backed code only once the connector is authorized, and read its token in server code by connection mode: shared → `context.getBase44().asServiceRole.connectors.getConnection(integration_type)`; BYO shared → `context.getBase44().asServiceRole.connectors.getWorkspaceConnection(connectorId)` (see ); app user → `context.getBase44().asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` for the signed-in app user, after the frontend let them consent with `connectAppUser(connectorId)` (see ). Never serve one app user's data through the shared account's token. Then make the API calls yourself. + + + + +A workspace-registered OAuth app (an `OrganizationConnector`) that the app builder consents once against. The resulting token is shared across all app users — same semantics as a SHARED connector, but the OAuth credentials come from the workspace admin (not the platform) and so the connection is keyed by `connectorId` rather than `integration_type`. + +**Prerequisite — the workspace connector must already exist.** A workspace admin registers it in workspace settings → Connectors ("Manage connectors" for users on the new integrations view) with the OAuth client_id, client_secret and any integration-specific config such as a Snowflake subdomain; its `id` is the `connectorId` below. If none exists for the integration the user needs, tell the user to have their admin register one, then retry. + +The app builder consents once against the connector from the Base44 builder chat (ask it to connect the workspace connector); the MCP `initiate_connector_connection` tool connects platform-shared connectors by integration type only. + +**No frontend code is generated for this mode** — consent is a one-time build-time action, not a runtime action in the published app. Unlike APP_USER, there is no `connectAppUser` call. + +**Server function — use `getWorkspaceConnection(connectorId)`** (`src/lib/server-fns.js`): + +``` +import { createServerFn } from "@tanstack/react-start"; +import { requireUser } from "@/lib/auth-middleware"; + +// asServiceRole reaches the workspace's shared account: require a signed-in caller, and check +// this user may see what the query returns before returning it. +export const countRows = createServerFn({ method: "GET" }).middleware([requireUser]).handler(async ({ context }) => { + // connectorId is the workspace connector's id, e.g. "abc123" + const { accessToken, connectionConfig } = await context.getBase44().asServiceRole.connectors.getWorkspaceConnection("abc123"); + + // connectionConfig carries any admin-configured parameters such as + // subdomain, workspace URL, etc. Consult the integration's usage guide + // for the exact field name. + const apiUrl = `https://${connectionConfig.subdomain}.snowflakecomputing.com/api/v2/statements`; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ statement: "SELECT COUNT(*) FROM my_table" }), + }); + if (!response.ok) throw new Error(`Snowflake ${response.status}`); + return await response.json(); // return data, never the token +}); +``` + +Differences from SHARED code-gen: +- A dedicated `getWorkspaceConnection(connectorId)` method is called with the connector's `id` as a string — NOT `getConnection`, which is reserved for platform-shared connectors keyed by `""`. +- `connectionConfig` is sourced from the workspace admin's `OrganizationConnector`, not per-app — so whatever the admin configured at registration time is what you get at runtime. + + + +App-user connectors are registered per workspace (workspace settings → Connectors); each has an `id`, the `connectorId` below. If one is already registered for that integration type, use its `id` — do not register another. Register a second connector of the same type only when the user asks for a separate one (a different account or OAuth app); when that is unclear, ask which they want instead of registering. + +When none is registered yet, the user registers one there with the integration type, a name no existing connector uses, a description and the minimal scopes the request needs — suggest those from the provider's OAuth documentation. + +When implementing app-user connector functionality: +1. **Connector**: Use the connector already registered for that integration type; the user registers a new one only when none exists. +2. **Server function**: Reuse an existing server function when it already provides the requested connector-backed behavior. If none does, add one (`createServerFn` in `src/lib/server-fns.js`) that uses `context.getBase44().asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` to get the *current app user's* connection (access token + connection config) and call the external API. Use a server function, not a server route: the page's call carries the signed-in app user, which is who `getCurrentAppUserConnection` resolves. +3. **Frontend**: Generate the connect/disconnect UI and data fetching, following the three rules in ``. Use the connector's `id` as the `connectorId`. +4. **In-app agents**: Give an agent app-user connector permission only when it should help app users connect or reconnect that account — it gives the agent the connect link, not the provider's API. In the agent's config, `app_user_connector_configs` takes the exact connector `id` as `connector_id`; do not pass the integration type or connector name. + + +**APP_USER mode** — requires BOTH frontend code (to let each app user consent) AND a server function (to use the current app user's token). + +All APP_USER SDK methods are keyed on **connectorId** (the connector's `id` in the dashboard) — NOT the integration type. + +**SDK methods:** + +| Connector mode | Frontend | Server code (`context.getBase44()`) | +|---|---|---| +| SHARED | None (no frontend SDK) | `context.getBase44().asServiceRole.connectors.getConnection(type)` | +| BYO_SHARED | None (no frontend SDK — consent is at build time) | `context.getBase44().asServiceRole.connectors.getWorkspaceConnection(connectorId)` | +| APP_USER | `base44.connectors.connectAppUser(connectorId)`, `base44.connectors.disconnectAppUser(connectorId)` | `context.getBase44().asServiceRole.connectors.getCurrentAppUserConnection(connectorId)` | + +- `connectAppUser(connectorId)` — returns a URL string. Open it so the app user can complete OAuth consent. +- `disconnectAppUser(connectorId)` — revokes the OAuth connection server-side, returns nothing. +- `getCurrentAppUserConnection(connectorId)` — returns a connection object `{ accessToken, connectionConfig }` for the **app user's** OAuth connection (not the builder's). Destructure `accessToken` and optionally `connectionConfig` (e.g. `const { accessToken, connectionConfig } = await ...getCurrentAppUserConnection(connectorId)`). `connectionConfig` may contain connector-specific parameters (e.g. subdomain, workspace URL) — only use it when the connector's usage guide says to. + +**Server function example** (`src/lib/server-fns.js`): + +``` +import { createServerFn } from "@tanstack/react-start"; +import { requireUser } from "@/lib/auth-middleware"; + +// requireUser rejects anonymous callers with 401: the connection belongs to the signed-in app user. +export const listMyEvents = createServerFn({ method: "GET" }) + .middleware([requireUser]) + .handler(async ({ context }) => { + // connectorId is the registered connector's id, e.g. "abc123" + const connection = await context.getBase44().asServiceRole.connectors + .getCurrentAppUserConnection(connectorId) + .catch((error) => { + if (error?.status === 404) return null; // this app user has not connected (or has revoked) the account + throw error; + }); + if (!connection) return { connected: false }; + + const response = await fetch('https://www.googleapis.com/calendar/v3/calendars/primary/events', { + headers: { 'Authorization': `Bearer ${connection.accessToken}` } + }); + if (!response.ok) throw new Error(`Google Calendar ${response.status}`); // a provider failure, not a disconnect + const data = await response.json(); + return { connected: true, events: data.items || [] }; // return data, never the token + }); +``` + + + +**Three rules you MUST follow when generating frontend code that uses app user connectors. Design the page UI however fits the app — but always apply these rules:** + +**Rule 1 — Auth gate:** `connectAppUser()` will fail if the app user is not signed in. Use `base44.auth.isAuthenticated()` to check before rendering any connector UI (Connect button, data that depends on the connector). If not authenticated, show a login button that calls `base44.auth.redirectToLogin()`. + +**Rule 2 — Connection status via data fetch:** There is no separate "check connection" API. Instead, call the server function that uses the connector on page load. It returns `{ connected: false }` when the app user has not connected the account → show the Connect button; otherwise it returns the data. If it throws, something else failed (the provider errored, the network dropped) → show an error and keep the connection state — never send the user to reconnect for it. Extract this fetch as a reusable function — you will call it again in Rule 3. + +**Rule 3 — Auto-refresh after OAuth:** The OAuth flow opens in a new tab. When the user finishes and the tab closes, the original page doesn't know. To fix this: use `window.open()` to keep a reference to the popup, then poll `popup.closed` — when it closes, call the reusable fetch from Rule 2 to refresh data automatically. + +**How the three rules connect (hooks/handlers skeleton):** +``` +const [user, setUser] = useState(null); +const [data, setData] = useState(null); +const [connected, setConnected] = useState(false); +const [loading, setLoading] = useState(true); +const [error, setError] = useState(null); + +// Rule 2: reusable fetch — doubles as connection check AND data loader +const fetchData = async () => { + try { + const res = await listMyEvents(); // the server function, imported from "@/lib/server-fns" + setConnected(res.connected); + setData(res.connected ? res : null); + setError(null); + } catch (e) { + setError(e); // provider or network failure: show it, keep the connection state + } +}; + +// Rule 1+2: check auth first, then fetch to detect connection status +useEffect(() => { + base44.auth.isAuthenticated().then(async (authed) => { + if (authed) { + const me = await base44.auth.me(); + setUser(me); + await fetchData(); + } + setLoading(false); + }); +}, []); + +// Rule 3: open OAuth popup, poll for close, then re-fetch +const handleConnect = async () => { + const url = await base44.connectors.connectAppUser(CONNECTOR_ID); + const popup = window.open(url, "_blank"); + const timer = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(timer); + fetchData(); + } + }, 500); +}; + +const handleDisconnect = async () => { + await base44.connectors.disconnectAppUser(CONNECTOR_ID); + setConnected(false); + setData(null); +}; + +// Then in your JSX, use loading, user, and connected to decide what to render. +// If !user, show a login button that calls base44.auth.redirectToLogin(). +// Design the UI however fits the app. +``` + + diff --git a/skills/base44-platform-entities/SKILL.md b/skills/base44-platform-entities/SKILL.md new file mode 100644 index 0000000..b48db68 --- /dev/null +++ b/skills/base44-platform-entities/SKILL.md @@ -0,0 +1,28 @@ +--- +name: base44-platform-entities +description: "Base44 entities: the JSON-schema data models under base44/entities/, the entity SDK, and row-level security (RLS) rules. Use when adding or changing stored data, queries, or per-user access rules in a Base44 app." +--- + +# Base44 entities + +Entities are JSON-schema objects stored as `base44/entities/.jsonc`; writing the file is what defines the data model. The platform applies each entity's `rls` rules on every app-user request. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +## For every app + +- [Row-level security (RLS)](references/rls.md) + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Entities and the entity SDK (web apps)](references/web.md) | +| Web app, TanStack Start | [Entities and the entity SDK (web apps)](references/web.md) | +| Native mobile app (Expo, React Native) | [Entities and the entity SDK (mobile apps)](references/mobile.md) | diff --git a/skills/base44-platform-entities/references/mobile.md b/skills/base44-platform-entities/references/mobile.md new file mode 100644 index 0000000..faf507a --- /dev/null +++ b/skills/base44-platform-entities/references/mobile.md @@ -0,0 +1,114 @@ + +# Base44 entities: Entities and the entity SDK (mobile apps) + +Entities are defined as json schema objects that define the shape of the data that is stored in the app. + + +IMPORTANT: +Every entity records has the built in attributes (no need to specify them in the schema): +- id +- created_date +- updated_date +- created_by_id (the ID of the user that created the record) + + + +Restoring entities is possible from the UI. + + +User entity is a built-in entity that exists on every app. +VERY IMPORTANT: YOU DON'T NEED TO CREATE THE USER ENTITY YOURSELF. it is created automatically and available for use on any app. + +Read-only attributes (you can not change them, you don't need to define them in the schema): +- id +- created_date +- full_name +- email + +Editable attributes: +- role (default values are 'admin' and 'user', but you can freely change, extend, or remove role values to fit the app's needs) + Example: changing roles to "manager", "employee", "viewer" or any other set of roles + +To customize role values or add additional attributes, edit `base44/entities/User.jsonc`. +Only include the fields you want to add or customize - built-in attributes (id, created_date, full_name, email) are included automatically. +Example: To change roles to 'manager', 'employee', 'viewer', include a "role" property with a custom enum. + +IMPORTANT: The User entity has special built-in security rules that only allow admin users to list, update, or delete other users. Regular users can only view and update their own user record. +Do not add custom User RLS unless the user explicitly asks to customize User access behavior. + + + +IMPORTANT: Never store large content (base64 images, PDFs, HTML blobs, binary data) directly on entity fields. Instead, upload files via UploadPrivateFile for anything an end user uploads and store only the returned file_uri. + + + +The Entities SDK can be imported from the pre-initialized base44 sdk client. + +use it like this: +import { base44 } from '@/api/base44Client'; +base44.entities.EntityName.list() will return the list of entities. + +e.g. if there's an entity named Todo, here's how to use the SDK: +import { base44 } from '@/api/base44Client'; +base44.entities.Todo.list('-updated_date', 20) will return the 20 most recently updated todos. +base44.entities.Todo.filter({status: 'active', created_by_id: user.id}, '-created_date', 10) will return the top 10 recently created active todos created by the current user. +base44.entities.Todo.create({title: "Todo 1", description: "Todo 1 description"}) will create a new todo. +base44.entities.Todo.bulkCreate([{title: "Todo 2", description: "abc"}, {title: "Todo 3", description: "123"}]) will create 2 new todos. +base44.entities.Todo.update(todo.id, {description: "new description"}) will update the todo with id 1. +base44.entities.Todo.updateMany({status: 'active'}, {$set: {status: 'done'}}) applies the same change to all matching todos (up to 500 records); returns has_more=true if remains; call again for the next batch — but your $set must change a field in the query; otherwise the same rows re-match forever. the 2nd arg uses MongoDB update operators (not a plain {field: value}): $set, $rename, $unset, $inc, $mul, $min, $max, $currentDate, $addToSet, $push, $pull. careful: no schema validation. +base44.entities.Todo.bulkUpdate([{id: id1, status: 'done'}, {id: id2, status: 'open'}]) will update many todos with different values in one call (up to 500). +base44.entities.Todo.delete(todo.id) will delete the todo with id 1. +base44.entities.Todo.deleteMany({status: 'archived'}) will delete all todos matching the query in one call. careful: a broad or empty ({}) query matches and deletes everything it's allowed to. + +in addition, you can use the built-in user entity through the SDK like this: +import { base44 } from '@/api/base44Client'; +await base44.entities.User.list() ... + +the authentication SDK provides special methods for the user entity: +await base44.auth.updateMe(data) - use this to update the current user's data. + +and if you want to access the current user, you can use the built-in user object: +const user = await base44.auth.me(); // can throw an error if the user is not logged in and the app is public. + +base44.auth.logout(redirectUrl?: string) - use this to logout the current user, redirects to the redirectUrl if provided, reloads the page otherwise. + +base44.auth.redirectToLogin(nextUrl?: string) - use this to login (this will redirect the user to the app's login page, +after successful login the user will be automatically redirected to the nextUrl if provided, +otherwise the user will be redirected to the page from which the login was triggered). + +to understand if the user is authenticated, you can use the base44.auth.isAuthenticated() which returns Promise. + +to save additional data on the current user, you can use the base44.auth.updateMe(data) +and data will be persisted (you can access it using await base44.auth.me()). +You can't override the built-in user attributes (id, email, full_name, role). + +all entity methods are pretty reliable, there's no need to try and catch errors. + +Real-time subscriptions - subscribe to entity changes in real-time: + +// Subscribe to ALL changes on an entity type (create, update, delete) +const unsubscribe = base44.entities.Todo.subscribe((event) => { + console.log(`Todo ${event.id} was ${event.type}d`); + console.log('Data:', event.data); +}); + +// Cleanup when done +unsubscribe(); + +// Example usage in a React component: +useEffect(() => { + const unsubscribe = base44.entities.Todo.subscribe((event) => { + if (event.type === 'create') { + setTodos(prev => [...prev, event.data]); + } else if (event.type === 'update') { + setTodos(prev => prev.map(t => t.id === event.id ? event.data : t)); + } else if (event.type === 'delete') { + setTodos(prev => prev.filter(t => t.id !== event.id)); + } + }); + + return unsubscribe; +}, []); + diff --git a/skills/base44-platform-entities/references/rls.md b/skills/base44-platform-entities/references/rls.md new file mode 100644 index 0000000..836d853 --- /dev/null +++ b/skills/base44-platform-entities/references/rls.md @@ -0,0 +1,144 @@ + +# Base44 entities: Row-level security (RLS) + + + + +Row-Level Security (RLS) decides which app users can read, create, update, or delete each record of an entity. You set it per entity in `base44/entities/.jsonc` under an `rls` key, and the platform applies it on every app-user request. + +Mental model: each operation gets a Mongo-style filter, and a user may perform that operation on the records that match the filter. An operation with no rule stays open to everyone — including anonymous visitors on public / no-login apps — which is right for genuinely shared data and wrong for anything personal. + + + +{ + "rls": { + "read": { ... }, + "create": { ... }, + "update": { ... }, + "delete": { ... } + } +} +- Leave an operation open by omitting its key (or using `{}` or `true`). +- Set the operations you want to protect. It's easy to lock reads and forget writes, so when the goal is "make it private," cover read, update, and delete together. `create` is usually fine left open — users make their own records, and the platform stamps the creator automatically. Two cases do want an explicit `create` rule: public / no-login apps (otherwise anonymous visitors can insert records), and scopes keyed on a field the creator sets — like `data.tenant_id` or `data.members` — so a user can't plant a record in someone else's scope (e.g. `"create": { "data.tenant_id": "{{user.data.tenant_id}}" }`). + + + +Records carry a few built-in fields at the top level — `id`, `created_by_id` (the creator's user id, your reliable "owner" handle), `created_by` (creator email), `created_date`, `updated_date`. Reference these directly. +Every field you defined on the entity lives under `data.`, so reference it with that prefix: a `status` field is `data.status`, a `members` field is `data.members`. + +Describe the current user with `{{...}}` templates: +- `{{user.id}}` — pair with `created_by_id` for ownership +- `{{user.email}}` +- `{{user.role}}` — "admin" or "user" +- `{{user.data.}}` — a custom field on the user, one level deep (e.g. `{{user.data.tenant_id}}`) +- When a user field is itself an array (e.g. `{{user.data.departments}}`), match a record field against the whole template as the `$in` operand: `{ "data.department": { "$in": "{{user.data.departments}}" } }` — the template is the entire value. Only this whole-operand form resolves; an array template used as a direct equality value (`{ "data.department": "{{user.data.departments}}" }`) or wrapped in a list (`{ "$in": ["{{user.data.departments}}"] }`) is left verbatim and matches nothing. + +Users can edit their own custom User fields (`base44.auth.updateMe`), so a rule may only trust a value the user can't set themselves: `{{user.id}}`, `{{user.email}}`, `{{user.role}}`, record fields the write rules protect (`created_by_id`, a `members` list), or a custom User field locked as in the_user_entity. An unlocked `{{user.data.}}` or custom field in `user_condition` lets a user set their own `tenant_id` to another tenant's and get that tenant's records. + +Gate on a user value when every relevant user actually has it set. If a user's field is empty the template resolves to null, which matches records whose field is also empty rather than blocking — so `created_by_id` (always present) is the most dependable anchor, and custom user fields work well once you make sure they're populated. + + + +To allow access based on who the user is rather than a field on the record, use `user_condition`, which compares user attributes for exact equality: + { "user_condition": { "role": "admin" } } +Keep `user_condition` as the only key in its object, and combine it with record conditions through `$or` / `$and` (see owner-or-admin below). A `$or` / `$and` / `$nor` is the same way — it must be the only key in its object, so to require a logical group *and* another condition, wrap both in an outer `$and` (e.g. `{ "$and": [ { "$or": [...] }, { "data.tenant_id": "{{user.data.tenant_id}}" } ] }`) rather than placing the field beside the `$or`. Use a plain value inside `user_condition` — it matches by exact equality. Role checks use "admin" (the elevated role) against the default "user". + + + +Reads run as a database query and accept the comparison and array operators — `$eq`/`$ne`, `$in`/`$nin`, `$gt`/`$gte`/`$lt`/`$lte`, `$exists`, `$all`, `$size`, `$elemMatch` — plus `$or`/`$and`/`$nor`. (Text search like `$regex` isn't available, so scope reads by exact field values.) Writes (create / update / delete) are checked against the single record in application code, which understands a focused, dependable set — build write rules from these and they behave exactly as written: + - equality: `{ "created_by_id": "{{user.id}}" }` + - `$in` / `$nin` with a list: `{ "data.status": { "$in": ["active", "trial"] } }` + - `$ne` with a single value: `{ "data.status": { "$ne": "archived" } }` + - `$or` / `$and`, alternating when you nest them (an `$or` holding `$and` branches, and vice-versa) + - `user_condition` + - top-level `data.` fields, i.e. a single-segment key like `data.tenant_id` + +Two natural adjustments for writes: + - Membership: on reads, `{ "data.members": "{{user.id}}" }` matches when the array contains the user. For writes, write the same idea as `{ "data.members": { "$in": ["{{user.id}}"] } }`. + - The read-only operators above (ranges, `$exists`, `$all`, `$size`, `$elemMatch`, and `$nor`) are great on `read`; keep write rules to the compact set above so they enforce cleanly. + +Handy detail: when `update`/`delete` are absent they inherit `write`, then `read`. So if an entity's protection currently lives on `read`, add matching explicit `update`/`delete` rules before you open `read` up, so writes stay covered. + + + +Match the pattern to what the data is; `created_by_id` is the owner throughout. + +Private to each user — their own records only (read / update / delete): + { "created_by_id": "{{user.id}}" } + +Owner, and admins see and manage everything: + { "$or": [ { "created_by_id": "{{user.id}}" }, { "user_condition": { "role": "admin" } } ] } + +Public to read, only the author edits — read open, writes owner-scoped: + "read": {}, + "create": { "created_by_id": "{{user.id}}" }, + "update": { "created_by_id": "{{user.id}}" }, + "delete": { "created_by_id": "{{user.id}}" } + +Everyone reads; only admins create or edit (announcements, shared catalog): + "read": {}, + "create": { "user_condition": { "role": "admin" } }, + "update": { "user_condition": { "role": "admin" } }, + "delete": { "user_condition": { "role": "admin" } } + +Visitor submissions the app's operator reviews (contact, booking, intake forms) — anyone submits; a logged-in submitter sees their own; only admins (plus any role that handles them) change them: + "create": {}, + "read": { "$or": [ { "created_by_id": "{{user.id}}" }, { "user_condition": { "role": "admin" } } ] }, + "update": { "user_condition": { "role": "admin" } }, + "delete": { "user_condition": { "role": "admin" } } + (If visitors who don't log in need to see their own submission later — a status page, a receipt — keep read closed. When they submit, save a long random code from a secure source (`crypto.randomUUID()`) in its own field and give it to them (not the record id or their email, which can be guessed). Look it up in a backend or server function with the service-role client (`asServiceRole`). It is a deliberately public endpoint: skip the sign-in check, since the code is the check. Reject a missing or blank code before the query, and match it exactly, never as a regex. Return only the fields they need.) + +Team / shared records — owner, a listed member, or an admin: + read: { "$or": [ { "created_by_id": "{{user.id}}" }, { "data.members": "{{user.id}}" }, { "user_condition": { "role": "admin" } } ] } + write: { "$or": [ { "created_by_id": "{{user.id}}" }, { "data.members": { "$in": ["{{user.id}}"] } }, { "user_condition": { "role": "admin" } } ] } + (If members only need to view, owner-or-admin on the write ops is simpler.) + This assumes `data.members` is a list of user IDs (plain strings). If it's a list of objects (e.g. `[{ "user_id": ... }]`), match the sub-field on reads — `{ "data.members.user_id": "{{user.id}}" }` — because a plain value compared to a list of objects matches nothing. For write access there's no clean form (the write gate can't reach into an object list), so anchor member writes on `created_by_id`/admin, or denormalize to a list of plain user-ID strings. + +Multi-tenant — each user sees only their organization's data (user carries `data.tenant_id`, so do the records). Apply to read/update/delete, and to create so a user can only add records in their own tenant, and lock the User's `tenant_id` (see the_user_entity): + { "data.tenant_id": "{{user.data.tenant_id}}" } + +Published-or-mine — a visibility state anyone can read, plus your own drafts: + read: { "$or": [ { "data.status": "published" }, { "created_by_id": "{{user.id}}" } ] } + create/update/delete: { "created_by_id": "{{user.id}}" } + (A plain `{ "data.status": "published" }` read is a fine visibility filter on its own; add the owner branch when people also need their own records. Combine visibility with identity using `$or`, or require both with `$and`.) + +One extra care on update: when access depends on a field the user could change (`data.tenant_id`, `data.members`), base the access decision on the unchangeable `created_by_id` rather than that field, so a user editing the field can't quietly grant or revoke their own access. A top-level `update` rule authorizes the *existing* record before the new values are applied — it gates who may edit the record, not which fields they may touch — so `created_by_id` is the dependable anchor here. + + + +A good rule is precise and lets the intended users through. Three quick checks keep it that way: +- Anchor on a field the app guarantees on every record. `created_by_id` is set automatically on every record, so an owner rule always lets owners in — it's the safest anchor. A custom field works as an anchor only when the app fills it on every record (for example a required field set at creation); gating on a field that's sometimes empty locks out users whose records don't have it. Live population isn't something you can check at build time, so prefer `created_by_id` unless you know the field is always set. +- Reference fields that exist on the entity's schema (with the `data.` prefix for custom ones). A rule pointing at a field the records don't have matches nothing. +- Read the rule back from the owner's and admin's perspective and confirm they still satisfy it. +- To close an operation entirely, point it at whoever should still have access — for example admin-only (`{ "user_condition": { "role": "admin" } }`). Keep the condition real and satisfiable: an impossible one like `{ "id": { "$exists": false } }` matches no record, so it shuts everyone out (owners included) rather than restricting a subset. +When a rule matches no one, reads come back empty and writes are refused — so this precision is what separates "secured" from "locked out." + + + +Read the entity's current `rls` first, then change only what needs changing: +- An operation already protected by a rule that fits the intent is done — keep it as-is. Rewording a working rule into a lateral or looser variant just adds churn; a change earns its place when it tightens access, fixes a rule that locks people out, or closes an operation that's still open. +- For an operation that is open but should be restricted, add a rule following the patterns above. +- For a whole-app request ("secure everything", "lock it down"), cover every entity that holds user data, not just the obvious ones — it's easy to secure most and leave one open. +- When a rule locks users out (see the checks above), repair it into the nearest correct rule that keeps the intended restriction, rather than dropping the protection. +- Respect deliberate choices. An operation the user wants public stays open. A `read` or `delete` set to `false` means "no one" on purpose, so leave it; on `create`/`update`, `false` isn't enforced, so treat those as open and give them a real rule if they should be restricted. A legacy top-level `write: false` behaves the same way — it denies `delete` but leaves `update` open — so replace it with explicit `update`/`delete` rules when writes should stay locked. +Change what differs from the current config, and leave the rest. + + + +The built-in `User` entity is governed by the platform's own permissions (for example, only admins manage other users), and a top-level `rls` on `User` isn't applied — so rely on those built-in permissions rather than adding one. For role-based access, put the `user_condition` role checks on the other entities' rules, where they take effect. + +Field-level rules on `User` properties do apply. A field lock is not the custom User RLS the entity guide tells you to skip, so add it even when the user didn't ask for User rules. Every custom User field another entity's rule trusts (`{{user.data.tenant_id}}`, `user_condition: { "data.department": ... }`) gets a write lock in `base44/entities/User.jsonc` — add its entry to the file's existing `properties`, keeping the fields already there: + "properties": { "tenant_id": { "type": "string", "rls": { "update": { "user_condition": { "collaborator_role": "editor" } } } } } +App users, app admins included, can then no longer change it, on first set too; the app's editors still can from the dashboard. Assign it in a backend or server function with the service-role client (`asServiceRole`, which skips these rules), after checking the user is entitled to it: an invite code, an admin's assignment, the tenant's verified domain. Backend functions need a paid plan; in an app with neither backend functions nor server functions, only the app's editors can set the field, from the dashboard, so if that won't scale use `created_by_id` or a `members` list instead. Never let a user pick their own tenant, company, or access level from the app's UI. A field that only holds the user's current choice among tenants (`active_tenant_id`) is not a boundary: the rule must also check membership in a list the user can't edit. + + + +1. Open `base44/entities/.jsonc`, note the owner field (`created_by_id`) and any custom field you'll reference. +2. Write one complete `rls` object covering each operation you're protecting, using the write-safe forms above and keeping any protection that should remain. +3. Save the file — the rules apply immediately to app-user requests. +4. Tell the user which operations you locked and to which rule, and confirm owners and admins keep the access they need. + + + diff --git a/skills/base44-platform-entities/references/web.md b/skills/base44-platform-entities/references/web.md new file mode 100644 index 0000000..ffd99fb --- /dev/null +++ b/skills/base44-platform-entities/references/web.md @@ -0,0 +1,57 @@ + +# Base44 entities: Entities and the entity SDK (web apps) + +Entities = JSON schema objects defining stored data. Built-in attributes on every record (never declare): id, created_date, updated_date, created_by_id. + + +Restoring entities is possible from the UI. + + +User is built-in on every app — never create it. Read-only: id, created_date, full_name, email. Editable: role (defaults 'admin'/'user'; freely change values by editing base44/entities/User.jsonc, declaring only customized fields). Built-in security: only admins list/update/delete other users. Don't add custom User RLS unless explicitly asked. You cannot insert User records — users join via invites; say so if asked. + + + +Never store large content (base64, PDFs, blobs) in entity fields — upload via UploadPrivateFile for anything an end user uploads, store the returned file_uri; oversized fields break record operations. + + + +Entities SDK on the pre-initialized base44 client; each entity is base44.entities.EntityName. The server filters, counts and sums, not JS on loaded records: a list on screen is one page with a "load more"; a number is count; totals per group or a chart is aggregate; a filter or search box is a new filter() call. + +```js +import { base44 } from '@/api/base44Client'; + +base44.entities.Todo.filter({status: 'open', priority: {$in: ['high', 'urgent']}}, {sort: '-created_date', limit: 50}) // → {items, next_cursor, has_more}: with options it returns a page, read .items. Load more: the same call with cursor: page.next_cursor. Operators: $in/$nin/$gt/$gte/$lt/$lte/$ne/$exists/$or, $regex for text search. Same options on list({sort, limit, cursor}) +base44.entities.Todo.count({status: 'open'}) // a number: totals, badges, "N of M" +base44.entities.Todo.aggregate({query: {status: 'done'}, groupBy: 'owner', sum: ['amount']}) // {rows: [{owner, count, sum_amount}]}; also avg/min/max and dateBucket: {field: 'created_date', unit: 'day'|'week'|'month'}. One call covers all counters of an entity +base44.entities.Todo.list({distinct: 'category'}) // dropdown values: {items: values} +// An entity with field-level read rules rejects aggregate and distinct (400): use count and a filter page there +base44.entities.Todo.create({title: "Todo 1"}) +base44.entities.Todo.bulkCreate([{title: "Todo 2"}, {title: "Todo 3"}]) +base44.entities.Todo.update(todo.id, {description: "new"}) +base44.entities.Todo.updateMany({status: "active"}, {$set: {status: "done"}}) // same change to all matches; up to 500 records, returns has_more=true if remains — call again for the next batch, but your $set must change a field in the query or the same rows re-match forever. 2nd arg uses MongoDB update operators (not a plain {field: value}): $set, $rename, $unset, $inc, $mul, $min, $max, $currentDate, $addToSet, $push, $pull. careful: no schema validation +base44.entities.Todo.bulkUpdate([{id: id1, status: "done"}, {id: id2, status: "open"}]) // different change per record, up to 500 records +base44.entities.Todo.delete(todo.id) +base44.entities.Todo.deleteMany({status: "archived"}) // deletes all matches in one call. careful: a broad or empty ({}) query deletes everything it's allowed to +``` + + +Built-in User entity uses the same SDK for reads (await base44.entities.User.list(), …), but User records can't be created or imported (create returns 405) — invite users with `base44.users.inviteUser(email, role)`. count, aggregate and the cursor form are not available on User. Auth SDK: +- await base44.auth.me() — current user; can throw if not logged in on a public app. +- await base44.auth.updateMe(data) — persist extra data on the current user (returned by later me()); built-ins (id, email, full_name, role) can't be overridden. +- base44.auth.isAuthenticated() — Promise. +- base44.auth.logout(redirectUrl?: string) — then redirect to redirectUrl, or reload. +- base44.auth.redirectToLogin(nextUrl?: string) — app login page; after login → nextUrl, else the triggering page. + + + +```js +useEffect(() => { + // event: { id, type: 'create'|'update'|'delete', data } + const unsubscribe = base44.entities.Todo.subscribe((event) => { /* update state from event */ }); + return unsubscribe; +}, []); +``` + + diff --git a/skills/base44-platform-frontend/SKILL.md b/skills/base44-platform-frontend/SKILL.md new file mode 100644 index 0000000..62a9e92 --- /dev/null +++ b/skills/base44-platform-frontend/SKILL.md @@ -0,0 +1,24 @@ +--- +name: base44-platform-frontend +description: "Frontend coding standards for Base44 web apps: code conventions, the installed packages, routing (React Router in the Vite SPA, file routes in TanStack Start), Tailwind and design tokens, styling and componentization rules, npm installs, and how files are edited in the sandbox. Use when writing or changing pages, components or routes." +--- + +# Base44 frontend + +The builder's own frontend rules for Base44 web apps. Pick the reference for the app's framework; the TanStack reference ends with a worked example. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Web app, Vite SPA](references/web-spa.md) | +| Web app, TanStack Start | [Web app, TanStack Start](references/web-tanstack.md) | +| Native mobile app (Expo, React Native) | [Native mobile app (Expo, React Native, NativeWind)](references/mobile.md) | diff --git a/skills/base44-platform-frontend/references/mobile.md b/skills/base44-platform-frontend/references/mobile.md new file mode 100644 index 0000000..c0af9a8 --- /dev/null +++ b/skills/base44-platform-frontend/references/mobile.md @@ -0,0 +1,138 @@ + +# Base44 frontend: Native mobile app (Expo, React Native, NativeWind) + +When coding, you are an exceptional software developer, expert in React Native and Expo mobile development. +You will develop screens, and when needed - you can develop components or edit the root layout of the app. + +1. CRITICAL: Think HOLISTICALLY and COMPREHENSIVELY BEFORE making an edit to a file. This means: +- Consider ALL relevant files in the project +- Review ALL previous file changes and user modifications +- Analyze the entire project context and dependencies +- Anticipate potential impacts on other parts of the system + + This holistic approach is ABSOLUTELY ESSENTIAL for creating coherent and effective applications. + +2. When writing a screen or a component, it has to be exported as default and have the same name. +3. When making a change to a screen or a component, consider all existing functionality. DO NOT override existing functionality, unless explicitly asked to do so by the user. + +4. Making edits to existing code: edit the existing file in place; rewrite a file whole only when most of it changes. + +For entities - ALWAYS PROVIDE THE FULL JSON SCHEMA. NO COMMENTS OR PLACEHOLDERS FOR PREVIOUS PROPERTIES. + + +This is a React Native (Expo) app — there is NO DOM. These rules are absolute: +- NEVER use HTML elements (`div`, `span`, `p`, `h1`, `img`, `button`, `input`, `a`, `table`, etc.). Use React Native core components: `View`, `Text`, `Pressable`, `TextInput`, `ScrollView`, `Image` (from expo-image), `Modal`, `Switch`, `ActivityIndicator`. +- ALL text must be wrapped in a `` component. Raw strings inside a `` crash the app. +- There is NO shadcn/ui and NO `@/components/ui` folder. Build UI from React Native primitives styled with NativeWind. +- NO `window`, `document`, `localStorage`, or other browser globals. For URL params use expo-router's `useLocalSearchParams()`. +- Use `@shopify/flash-list` (`FlashList`) for any scrollable list of repeated data; `ScrollView` only for short, fixed content. +- Use `Pressable` for tappable elements (not `TouchableOpacity` — `Pressable` is the modern API). +- For icons use `lucide-react-native` — include only icons you know exist in the library; invalid icon imports break the app. Icons take `size` and `color` props (NOT className for color). +- Use `SafeAreaView` from `react-native-safe-area-context` as the top-level wrapper of every screen. +- Wrap text inputs in `KeyboardAvoidingView` patterns when they can be covered by the keyboard. +- Use `expo-haptics` for tactile feedback on meaningful actions; `expo-linear-gradient` for gradients (NOT CSS gradients). +- Animations: use `react-native-reanimated` (already configured). CSS transitions/animations do not exist. + + + +The app uses expo-router (file-based routing): +- Every file in `app/` is automatically a route: `app/index.jsx` → `/`, `app/settings.jsx` → `/settings`, `app/items/[id].jsx` → `/items/:id`. +- `app/_layout.jsx` is the root layout (Stack). For tab navigation create `app/(tabs)/_layout.jsx` with `Tabs` from expo-router and move tab screens into `app/(tabs)/`. +- Navigate with the `` component or `router.push("/settings")` from `import { router } from "expo-router"`. +- Read route params with `useLocalSearchParams()`. +- NEVER use react-router-dom — it does not exist in this project. + + + +Styling uses NativeWind v4 — Tailwind classes via the `className` prop on React Native components: +- `className="flex-1 items-center justify-center bg-slate-50"` works on View/Text/etc. +- Flexbox is the ONLY layout system (no CSS grid, no floats). `flex-row` for horizontal, default is column. +- Text styling classes (`text-lg`, `font-bold`, `text-slate-900`) go on ``, not on the wrapping ``. +- No hover states on touch devices — use Pressable's `({ pressed })` style callback for press feedback. +- Tailwind purges classes it cannot find as literal substrings in source at build time. Never construct class names dynamically (e.g. `bg-${color}-500`). Literal class strings in source — including inside const maps/arrays — ARE scanned. +- Shadows: use `shadow-sm`/`shadow-md` sparingly (they translate to native shadows); prefer borders and background contrast for depth. + + + + +Only the following packages are installed: +- react / react-native +- expo (and expo-constants, expo-linking, expo-status-bar, expo-haptics, expo-linear-gradient) +- expo-router (file-based navigation) +- expo-image (use instead of the RN Image for remote images) +- nativewind + tailwindcss (styling via className) +- react-native-reanimated (animations) +- react-native-gesture-handler (gestures; root view already wired) +- react-native-safe-area-context (SafeAreaView) +- react-native-screens +- react-native-svg +- @shopify/flash-list (performant lists) +- lucide-react-native (icons) +- @tanstack/react-query (data fetching and query state) +- date-fns +- lodash +- zod +- @/api/base44Client (pre-initialized Base44 SDK) + +EXTREMELY IMPORTANT: DO NOT USE ANY OTHER LIBRARIES other than those listed above. This will BREAK THE APP. In particular: no react-dom, no react-router-dom, no shadcn/ui, no framer-motion, no recharts, no moment. + + + +The entity SDK is documented in the `base44-platform-entities` skill (mobile reference). + + + +The integrations SDK is documented in the `base44-platform-integrations-sdk` skill (mobile reference). + + + + +You always create beautiful apps, with a clean and modern look that feels native to mobile. +Design mobile-first: generous touch targets (min 44pt), comfortable spacing, bottom-of-thumb reachability for primary actions. + +IMPORTANT: For all designs I ask you to make, have them be beautiful, not cookie cutter. +Make screens that are fully featured and worthy of a real app store app. + + +Unless the user asks for a different style, follow iOS design conventions — apps should feel at home on an iPhone: +- Typography hierarchy like iOS: one large bold screen title (text-3xl/4xl font-bold tracking-tight), 17pt-equivalent body (text-[17px]), 13pt-equivalent captions (text-[13px]). +- CONTRAST IS NON-NEGOTIABLE: primary text near-black (e.g. text-zinc-900) on light backgrounds; secondary text no lighter than zinc-500. NEVER set body or interactive text in washed-out tints — muted colors are for captions and disabled states only. +- One vivid accent color used decisively (iOS-style tints like blue #007AFF, indigo #5856D6, green #34C759) for primary buttons, active states, and links — at full saturation, not pastel. +- Surfaces: grouped-card look — white cards (rounded-2xl) on a faint gray page background (e.g. bg-zinc-100), hairline separators (border-zinc-200), subtle shadows only. +- Primary action buttons: filled with the accent color, white bold label, rounded-xl/2xl, 50pt tall — they should be impossible to miss. +- Spacing: 16-20pt screen padding, breathing room between sections; avoid cramped layouts. +- SF-Symbols-like iconography: lucide-react-native icons at consistent sizes (20-24), colored with the accent or zinc-400 — never near-invisible tints. + + +Use loading indicators (`ActivityIndicator`) when fetching data or using integrations. +Respect safe areas on every screen (notches, home indicator). + + + +- Prioritize creating small, focused files and components. +- Create a new file for every new component, under `src/components/` (screens live in `app/`). +- Aim for components that are 50 lines of code or less. +- When a screen's code gets too big, break it down into components. + + + +You work on the app's sandbox files directly: read, write and edit them by path and run shell commands, through the Base44 MCP sandbox tools or the `base44 sandbox` CLI. +- Entity files (`base44/entities/*.jsonc`) are stored as objects: always write the complete schema, never a partial patch or placeholders. +- Writes are committed automatically a few seconds after the change and synced into the platform: a resource file (entity, agent, function, workflow) is live once written; there is no deploy step. +- Only checkpoints appear in the builder's version history. Create a checkpoint when you finish a unit of work and before you stop; the next Restore in the builder discards everything after the last checkpoint. +- Verify with the project's own commands (`npm run build`, lint) before finishing. + + + + +Only install when user explicitly requests by name or npmjs.com URL. Use existing / installed packages otherwise. +Install exact package requested - do not substitute with alternatives. + + + +1. Check the installed packages list to verify not already installed +2. Install from the sandbox shell: `npm install @^` (for Expo native modules prefer `npx expo install `); stable versions, caret (^) for compatible updates. Remove a package the user no longer wants with `npm uninstall `. Never uninstall @base44/sdk or @base44/vite-plugin — the platform requires them. + + diff --git a/skills/base44-platform-frontend/references/web-spa.md b/skills/base44-platform-frontend/references/web-spa.md new file mode 100644 index 0000000..386a64a --- /dev/null +++ b/skills/base44-platform-frontend/references/web-spa.md @@ -0,0 +1,119 @@ + +# Base44 frontend: Web app, Vite SPA + +You develop pages and, when needed, components or the app's layout. + + +- Before editing, account for dependent files, earlier changes, and the user's own edits. +- Export every page/component as default, named same as its file. +- shadcn/ui from @/components/ui; Tailwind CSS for styling. +- Icons: lucide-react only, only icons that exist — a nonexistent or unimported icon breaks the whole app. +- Every import must resolve to a real file or package. +- URL params: const urlParams = new URLSearchParams(window.location.search). +- Handle just-registered users with nothing saved on the user entity yet. +- Entities: always the full JSON schema — no comments/placeholders; it replaces the stored schema. +- Let errors bubble up — no try/catch unless asked, since uncaught errors come back to you to fix. Exception: user-facing form/auth flows catch and show inline errors (that behavior is the feature). + + + + +Only these frontend packages are installed: React; tailwind css; shadcn/ui (all components); lucide-react; moment; recharts; react-quill-new; react-hook-form; react-router-dom; date-fns; lodash; react-markdown; framer-motion; three.js (3d models/games); react-leaflet (maps); @hello-pangea/dnd (drag and drop); @tanstack/react-query (data fetching/query state); @/api/base44Client (pre initialized sdk); @/utils. +Never use any other library — only this list plus shadcn components in @/components/ui/; anything else breaks the app. + + + +The entity SDK is documented in the `base44-platform-entities` skill. + + + +The integrations SDK (InvokeLLM, SendEmail, file uploads) is documented in the `base44-platform-integrations-sdk` skill. + + + +Analytics tracking is documented in the `base44-platform-integrations-sdk` skill. + + + + +src/App.jsx is the application router — a page without a Route there is unreachable. On page add/remove, update src/App.jsx in the same change: import near "// Add page imports here" + Route inside Routes. + + + with route paths from src/App.jsx, not createPageUrl. + + + +No automatic layout wrapping — don't create a root-level src/Layout.jsx. Shared layouts: a component (e.g., src/components/Layout.jsx) with React Router's , used as a layout route: + +```jsx +import { Outlet } from 'react-router-dom'; +export default function Layout() { return
; } +// src/App.jsx +}> + } /> + +``` +
+ + +- Main/home page on "/" only — no second route under the component name (both "/" and "/Home"). +- Changing the main page: the "/" route in src/App.jsx is the source of truth — no platform setting; point "/" at the desired component and import it at the top (don't send the user to dashboard settings). +- Unknown paths: keep `} />` last — never point it at Home. Every not-found screen (that page, and a detail page whose item does not exist — never on a failed load) appends `` to `document.head` in a `useEffect` and removes it on cleanup; crawlers then get a real 404. Only once loading has finished — a real page that flashes "not found" while its data loads would be served as a 404. + + +Keep these framework wrappers in src/App.jsx intact — auth, data fetching, routing, and toasts run through them: +- , , /, +- AuthenticatedApp and its auth logic (loading states, UserNotRegisteredError) +- ProtectedRoute from @/components/ProtectedRoute for per-route auth guarding + +
+ + +Write Tailwind classes as literal strings (const maps/arrays included) — the build purges anything not found as a literal substring in source; dynamic names (`bg-${color}-500`, `"bg-" + shade`) silently disappear. `safelist` in `tailwind.config.js` only for runtime-sourced values (entity records, API responses, user input), never for classes in source. + + + + +index.html — for page title, meta tags (SEO, Open Graph), external scripts (Analytics, AdSense), favicon, custom fonts. Always preserve: `` (runtime script injection), `
` (React mount), `` (entry). Once customized, served as-is — no automatic injections (SEO/social tags, canonical URLs, favicons). +
+
+ + +Build beautiful, clean, modern, fully featured, production-worthy, responsive (mobile + desktop) apps. + +Stock photos from Unsplash where appropriate — only valid URLs you know exist; link in image tags, don't download. + + +Render every content image (user uploads, generated images — any media.base44.com / static.wixstatic.com URL) with the `Image` component from `@/components/ui/image`, never a plain `` — it serves the image resized to its container, encoded for the browser, with responsive srcset. Size the box via `className`; `fittingType="fill"` (default, crops) or `"fit"` (contains); optional `focalPointX`/`focalPointY` (0–1) anchor the crop. Other hosts and SVGs pass through unchanged, so it is always safe. + + +Show loading indicators while integration calls are in flight. +Color token syntax must match its consumer: `hsl(var(--token))` requires HSL channels such as `0 0% 100%`; an RGB tuple such as `248 250 252` requires `rgb(var(--token))`. Never put unitless RGB channels into an HSL token. + + +- `src/index.css` owns token values; `tailwind.config.js` maps tokens to Tailwind classes. Theme changes: update values in `src/index.css`, use mapped classes in JSX. +- Colors: update both `:root` and `.dark`. +- Fonts: `@import url('...')` at top of `src/index.css`, before `@tailwind base;`; update font role tokens (`--font-heading`, `--font-body`, `--font-display`), adding them to `src/index.css`/`tailwind.config.js` if missing. +- JSX: mapped token classes (`bg-primary`, `font-heading`, `font-body`, `font-display`); no hardcoded values (`bg-[#ffffff]`, `bg-white`, `font-[Inter]`, inline `style={{ background: "#ffffff", fontFamily: "Inter" }}`). +- New custom tokens only when semantic tokens aren't enough and the value is reused: define under `:root` + `.dark` in `src/index.css`, map in `tailwind.config.js`, use the generated class. + + + + + +- Small focused files: components of 50 lines or less; every new component/page gets its own new file, never added to an existing one. +- Break oversized pages into components yourself — no need to ask. + + + +You work on the app's sandbox files directly: read, write and edit them by path and run shell commands, through the Base44 MCP sandbox tools or the `base44 sandbox` CLI. +- Entity files (`base44/entities/*.jsonc`) are stored as objects: always write the complete schema, never a partial patch or placeholders. +- Writes are committed automatically a few seconds after the change and synced into the platform: a resource file (entity, agent, function, workflow) is live once written; there is no deploy step. +- Only checkpoints appear in the builder's version history. Create a checkpoint when you finish a unit of work and before you stop; the next Restore in the builder discards everything after the last checkpoint. +- Verify with the project's own commands (`npm run build`, lint) before finishing. + + + +Install only when the user explicitly requests a package by name or npmjs.com URL — the exact package, no substitutes; otherwise use installed packages. Check the installed list first, then install from the sandbox shell (`npm install @^`): stable versions, caret (^) for compatible updates. Remove a package the user no longer wants with `npm uninstall`. Never uninstall @base44/sdk or @base44/vite-plugin — the platform requires them. + diff --git a/skills/base44-platform-frontend/references/web-tanstack.md b/skills/base44-platform-frontend/references/web-tanstack.md new file mode 100644 index 0000000..5e408b9 --- /dev/null +++ b/skills/base44-platform-frontend/references/web-tanstack.md @@ -0,0 +1,212 @@ + +# Base44 frontend: Web app, TanStack Start + +You develop pages and, when needed, components or the app's layout. + + +- Before editing, account for dependent files, earlier changes, and the user's own edits. +- Route files export `Route` (components are plain functions in the same file); every other component is a default export named same as its file. +- shadcn/ui from @/components/ui; Tailwind CSS for styling. +- Icons: lucide-react only, only icons that exist — a nonexistent or unimported icon breaks the whole app. +- Every import must resolve to a real file or package. +- URL params: `Route.useParams()` for path params, `validateSearch` + `Route.useSearch()` for the query string — never `window.location` in render. +- Handle just-registered users with nothing saved on the user entity yet. +- Entities: always the full JSON schema — no comments/placeholders; it replaces the stored schema. +- Let errors bubble up — no try/catch unless asked, since uncaught errors come back to you to fix. Exception: user-facing form/auth flows catch and show inline errors (that behavior is the feature). + + + + +Only these packages are installed: React 19; tailwind css; shadcn/ui (all components); lucide-react; moment; date-fns; lodash; recharts; react-hook-form + @hookform/resolvers + zod; react-day-picker; react-markdown; react-quill-new; react-hot-toast; sonner (toasts, via @/components/ui/sonner); next-themes; framer-motion; three.js (3d models/games); react-resizable-panels; react-leaflet (maps); @hello-pangea/dnd (drag and drop); canvas-confetti; html2canvas; jspdf; @stripe/stripe-js + @stripe/react-stripe-js; @tanstack/react-router; @tanstack/react-start; @tanstack/react-query (data fetching/query state); @base44/sdk; @/api/base44Client (pre-initialized browser sdk); @/lib/utils (`cn`). +Never use any other library — only this list plus shadcn components in @/components/ui/; anything else breaks the app. + + + +The entity SDK is documented in the `base44-platform-entities` skill. + + + +The integrations SDK (InvokeLLM, SendEmail, file uploads) is documented in the `base44-platform-integrations-sdk` skill. + + + +Analytics tracking is documented in the `base44-platform-integrations-sdk` skill. + + + + +Routing is file-based: every file under `src/routes/` is a route. `src/routeTree.gen.ts` is generated on every dev/build run: never write, edit or delete it, and write only `.js`/`.jsx` files — this app is JavaScript/JSX. +- `__root.jsx` — the document and providers (see ). +- `index.jsx` → `/` · `about.jsx` → `/about` · `posts.index.jsx` → `/posts` · `posts.$postId.jsx` → `/posts/:postId` · `posts.$postId_.edit.jsx` → `/posts/:postId/edit` · `$.jsx` catch-all. +- Dots and folders nest: `posts.jsx` is the parent of every `posts.*` route, as `posts/$postId.jsx` is of `posts/$postId/edit.jsx`, and a parent shows its child only through `` — without it the child URL silently renders the parent page. So list/detail/edit pages are siblings: `posts.index.jsx` + `posts.$postId.jsx` + `posts.$postId_.edit.jsx`, no `posts.jsx` (add one only as a shared layout with ``). A trailing `_` (`posts_.$postId.jsx`, `$postId_.edit`) opts out of the parent. +- `_authed.jsx` + `_authed/dashboard.jsx` — pathless layout: wraps its children (`/dashboard`) without adding a URL segment; `_authed.jsx` holds the login gate (`beforeLoad: requireSignedIn` from `@/components/auth/AuthGate`, see ), and its children read `Route.useRouteContext().user`. `(marketing)/pricing.jsx` — route group: organizes files, adds no segment. A folder (`posts/index.jsx`) equals the dotted name. +- `api/.js` — server routes (see ). +- Every route file: `export const Route = createFileRoute("/path")({ component, loader, head, … })` from `@tanstack/react-router`; the path string is the file's URL, and the plugin rewrites it on rename. + + + +- Reads: a `loader` (runs on the server for a document request, in the browser on client navigation) calling a server function; the component reads it with `Route.useLoaderData()`. `loaderDeps: ({ search }) => ({ … })` when the loader depends on search params. +- Writes: `useServerFn(fn)` + TanStack Query `useMutation`, then `useRouter().invalidate()` (or `queryClient.invalidateQueries`) so lists refresh. +- Page metadata: `head: () => ({ meta: [{ title }, { name: "description", content }], links: [...] })` per route — never a `` in JSX. +- `ssr: false` only on a route whose render or loader needs the signed-in user (`useAuth()`, `base44.auth.me()`, the user's own records), so personal data never lands in cacheable HTML, and on the auth pages and `oauth/consent`, wired with `ssr: false` as <authentication> shows. Every other page stays server-rendered — public pages anyone can open and pages that only keep browser state (localStorage, theme, drafts); `ssr: false` is never the fix for `window` or hydration. A library that touches `window`/`document` on import (maps, rich-text editors) goes in its own component, rendered as `lazy(() => import("@/components/Map"))` inside `<ClientOnly fallback={…}>` from `@tanstack/react-router`. Public pages may opt into edge caching with the route's `headers: () => ({ "cache-control": "public, s-maxage=…" })`. +- Auth is resolved client-side after hydration: `useAuth()` from `@/lib/AuthContext` (`user`, `isAuthenticated`, `isLoadingAuth`, `refresh`, `logout`, `navigateToLogin`). Gate user-specific UI on `isLoadingAuth`; the server renders every page anonymously. +- Search params: make each `validateSearch` param optional and apply its default where it is read, since a default returned from `validateSearch` redirects `/` to `/?param=…`. +- Server and first client render must match, or React throws a hydration error (a bug to fix, not a warning). `window`, `document` and browser storage only inside effects and handlers — never at module scope, in render or in a `useState` initializer, even behind `typeof window`: render the server's default, then load the stored value in `useEffect`. Format dates and numbers with an explicit locale and time zone (`toLocaleString("en-US", { timeZone: "UTC" })`); render `Date.now()`/random values only after mount. +</data_and_rendering> + +<navigation> +`<Link to="/posts/$postId" params={{ postId }}>` and `useNavigate()` from `@tanstack/react-router`; paths are the file names above — never `createPageUrl`, `react-router-dom` or `<a href>` for in-app links. +Every screen, tab and section a user can navigate to is its own route file with its own URL and `head()` — never one route switching screens with `useState` or a `?tab=` param. +The main/home page (`/`) is exactly one file: `src/routes/index.jsx`, or `_authed/index.jsx` / `_site/index.jsx` once it moves under a layout (move it, never copy it, so two files never claim `/`); changing the main page means editing that file — no platform setting. The starter's home page is a placeholder: replace the whole file rather than patching it. +The starter has no app chrome. A shared header, nav or sidebar is a component (e.g. `src/components/Layout.jsx`) rendered around `<Outlet />` in a layout route's `component`: `__root.jsx` while the app has no auth routes; `_authed.jsx` once it has them, with its gated pages under `_authed/`; and if it also keeps public pages, a pathless `_site.jsx` rendering the same layout component, with those pages (the home page included) under `_site/`. The auth pages and `oauth/consent` stay outside every such layout and render full-screen. +</navigation> + +<tailwind_class_names> +Write Tailwind classes as literal strings (const maps/arrays included) — the build purges anything not found as a literal substring in source; dynamic names (`bg-${color}-500`, `"bg-" + shade`) silently disappear. `safelist` in `tailwind.config.js` only for runtime-sourced values (entity records, API responses, user input), never for classes in source. +</tailwind_class_names> + +<prebuilt_components> +<root_document> +`src/routes/__root.jsx` renders the document and providers. Site-wide title/meta/favicon/font links go in its `head()`; per-page ones in each route's `head()`. External scripts (Analytics, AdSense) go in `<body>` next to `<Base44Scripts />`, never in `head()`. Always preserve `data-react-root` on `<body>` and the order `<HeadContent />`, `<AuthProvider>`, `<Toaster />`, `<Base44Scripts />` immediately before `<Scripts />` — the platform's runtime hooks depend on it. +</root_document> +</prebuilt_components> + +<styling_instructions> +Build beautiful, clean, modern, fully featured, production-worthy, responsive (mobile + desktop) apps. + +Stock photos from Unsplash where appropriate — only valid URLs you know exist; link in image tags, don't download. + +<content_image_component> +Render every content image (user uploads, generated images — any media.base44.com / static.wixstatic.com URL) with the `Image` component from `@/components/ui/image`, never a plain `<img>` — it serves the image resized to its container, encoded for the browser, with responsive srcset. Size the box via `className`; `fittingType="fill"` (default, crops) or `"fit"` (contains); optional `focalPointX`/`focalPointY` (0–1) anchor the crop. Other hosts and SVGs pass through unchanged, so it is always safe. +</content_image_component> + +Show loading indicators while integration calls are in flight. +Color token syntax must match its consumer: `hsl(var(--token))` requires HSL channels such as `0 0% 100%`; an RGB tuple such as `248 250 252` requires `rgb(var(--token))`. Never put unitless RGB channels into an HSL token. + +<tokens_based_design_system> +- `src/index.css` owns token values; `tailwind.config.js` maps tokens to Tailwind classes. Theme changes: update values in `src/index.css`, use mapped classes in JSX. +- Colors: update both `:root` and `.dark`. +- Fonts: `@import url('...')` at top of `src/index.css`, before `@tailwind base;`; update font role tokens (`--font-heading`, `--font-body`, `--font-display`), adding them to `src/index.css`/`tailwind.config.js` if missing. +- JSX: mapped token classes (`bg-primary`, `font-heading`, `font-body`, `font-display`); no hardcoded values (`bg-[#ffffff]`, `bg-white`, `font-[Inter]`, inline `style={{ background: "#ffffff", fontFamily: "Inter" }}`). +- New custom tokens only when semantic tokens aren't enough and the value is reused: define under `:root` + `.dark` in `src/index.css`, map in `tailwind.config.js`, use the generated class. +</tokens_based_design_system> + +</styling_instructions> + +<componentization> +- Small focused files: components of 50 lines or less; every new component/page gets its own new file, never added to an existing one. +- Break oversized pages into components yourself — no need to ask. +</componentization> + +<working_with_files> +You work on the app's sandbox files directly: read, write and edit them by path and run shell commands, through the Base44 MCP sandbox tools or the `base44 sandbox` CLI. +- Entity files (`base44/entities/*.jsonc`) are stored as objects: always write the complete schema, never a partial patch or placeholders. +- Writes are committed automatically a few seconds after the change and synced into the platform: a resource file (entity, agent, function, workflow) is live once written; there is no deploy step. +- Only checkpoints appear in the builder's version history. Create a checkpoint when you finish a unit of work and before you stop; the next Restore in the builder discards everything after the last checkpoint. +- Verify with the project's own commands (`npm run build`, lint) before finishing. +</working_with_files> + +<npm_package_installation> +Install only when the user explicitly requests a package by name or npmjs.com URL — the exact package, no substitutes; otherwise use installed packages. Check the installed list first, then install from the sandbox shell (`npm install <package>@^<version>`): stable versions, caret (^) for compatible updates. Remove a package the user no longer wants with `npm uninstall`. Never uninstall @base44/sdk or @base44/vite-plugin — the platform requires them. +</npm_package_installation> + +<example> +Two short worked examples; each file is shown with its path. `…` elides boilerplate. + +<user_ask>Create a simple TODO app where I can track my tasks</user_ask> +<assistant> +``` +// base44/entities/Task.jsonc +{"name":"Task","type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"status":{"type":"string","enum":["todo","in_progress","done"],"default":"todo"},"priority":{"type":"string","enum":["low","medium","high"],"default":"medium"},"due_date":{"type":"string","format":"date"}},"required":["title"]} +``` +<note>Schema first; then all independent files in one parallel batch:</note> +``` +// src/lib/server-fns.js +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + +const TaskInput = z.object({ title: z.string().min(1), description: z.string().optional(), status: z.enum(["todo", "in_progress", "done"]).optional(), priority: z.enum(["low", "medium", "high"]).optional(), due_date: z.string().optional() }); +export const listTasks = createServerFn({ method: "GET" }).handler(({ context }) => + context.getBase44().entities.Task.list("-created_date", 50)); +export const createTask = createServerFn({ method: "POST" }) + .validator(TaskInput) + .handler(({ data, context }) => context.getBase44().entities.Task.create(data)); +export const updateTask = createServerFn({ method: "POST" }) + .validator(TaskInput.partial().extend({ id: z.string() })) + .handler(({ data: { id, ...patch }, context }) => context.getBase44().entities.Task.update(id, patch)); +… // deleteTask: same shape, called as deleteTask({ data: { id } }) +``` +``` +// src/components/tasks/TaskForm.jsx +import React from "react"; +import { Button } from "@/components/ui/button"; +… // controlled form for title/status/priority/due_date, onSubmit(currentTask) +``` +``` +// src/components/tasks/TaskItem.jsx +… // one task row: status toggle, priority badge, edit/delete actions +``` +``` +// src/routes/index.jsx +import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { useServerFn } from "@tanstack/react-start"; +import { listTasks, createTask, updateTask } from "@/lib/server-fns"; +import TaskForm from "@/components/tasks/TaskForm"; +import TaskItem from "@/components/tasks/TaskItem"; + +export const Route = createFileRoute("/")({ + ssr: false, // the signed-in user's own tasks — a public page (home, listing, detail anyone can open) omits this + loader: () => listTasks(), + head: () => ({ meta: [{ title: "Tasks" }] }), + component: TasksPage, +}); + +function TasksPage() { + const tasks = Route.useLoaderData(); + const router = useRouter(); + const create = useServerFn(createTask); + const update = useServerFn(updateTask); + … // renders form + filtered list, full CRUD wired: await create({ data: form }), await update({ data: { id: task.id, ...patch } }), then router.invalidate() +} +``` + +Your todo app is ready — add tasks, set priority and due dates, and move them between To Do, In Progress, and Done. +</assistant> + +<user_ask>show each item on its own page with its full details</user_ask> +<assistant> +<note>One focused route — exactly the ask, no extra sections/pages/animations. Add the server function it needs, load by id in the loader, and render every stored field (a view that drops the item's main text/body is incomplete).</note> +``` +// src/lib/server-fns.js +… // existing server functions unchanged +export const getTask = createServerFn({ method: "GET" }) + .validator(z.object({ id: z.string() })) + .handler(({ data, context }) => context.getBase44().entities.Task.get(data.id) + .catch((error) => { if (error.status === 404) return null; throw error; })); +``` +``` +// src/routes/tasks.$taskId.jsx +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { getTask } from "@/lib/server-fns"; + +export const Route = createFileRoute("/tasks/$taskId")({ + ssr: false, // the user's own task + loader: async ({ params }) => { + const task = await getTask({ data: { id: params.taskId } }); + if (!task) throw notFound(); + return task; + }, + head: ({ loaderData }) => ({ meta: [{ title: loaderData?.title ?? "Task" }] }), + component: TaskDetail, +}); + +function TaskDetail() { + const task = Route.useLoaderData(); + … // render the title and the full body/content — wrap content in the right component and pass the value in, never leave it empty +} +``` +Each item now opens on its own page with its full details. +</assistant> +</example> diff --git a/skills/base44-platform-integrations-sdk/SKILL.md b/skills/base44-platform-integrations-sdk/SKILL.md new file mode 100644 index 0000000..5d2986a --- /dev/null +++ b/skills/base44-platform-integrations-sdk/SKILL.md @@ -0,0 +1,24 @@ +--- +name: base44-platform-integrations-sdk +description: "Base44 built-in integrations on the SDK client: InvokeLLM, SendEmail, file uploads (public and private), signed URLs, data extraction and image generation, with every Core endpoint's input schema, plus analytics event tracking. Use when a feature needs AI, email, files or analytics without a third-party service." +--- +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 integrations sdk + +File uploads and signed URLs are called from the browser SDK; every other Core integration runs in server code on an app with core-integration control, the default for new apps (profile `core_integrations: backend_functions_only`). The endpoint schemas below are the ones the platform serves today. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Integrations and analytics SDK (Vite SPA)](references/web-spa.md) | +| Web app, TanStack Start | [Integrations and analytics SDK (TanStack Start)](references/web-tanstack.md) | +| Native mobile app (Expo, React Native) | [Integrations SDK (mobile apps)](references/mobile.md) | diff --git a/skills/base44-platform-integrations-sdk/references/mobile.md b/skills/base44-platform-integrations-sdk/references/mobile.md new file mode 100644 index 0000000..e0c846f --- /dev/null +++ b/skills/base44-platform-integrations-sdk/references/mobile.md @@ -0,0 +1,190 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 integrations sdk: Integrations SDK (mobile apps) + +Integrations are packages of functions that are used to interact with external data / services. + +You can use integration packages from the pre-initialized base44 sdk client. +and call spefici integrations by with the relevant parameters. + +<core_integrations_backend_functions> +All `base44.integrations.Core` SDK methods except `UploadPublicFile`, `UploadPrivateFile` and `CreateFileSignedUrl` must run inside a backend function using `base44.asServiceRole.integrations.Core`. Check every Core Integration call in the client code. The restricted methods cannot remain there. `UploadFile` is the retired spelling of `UploadPublicFile` and is equally client-callable. + +Use a clearly named, narrow app operation, not a 1:1 Core proxy: hardcode the Core method, expose and validate only the bounded inputs the feature needs, keep privileged configuration server-side, and return only the required result. Never accept an integration name plus an arbitrary payload for forwarding. +Limit each request's cost and scope (for example prompt/file size, item count, output count, or fixed recipients/destinations) and add feature-appropriate abuse controls when available. + +Upload files directly from the client with `UploadPublicFile` or `UploadPrivateFile`. Pass the resulting `file_url` or `file_uri` to a backend function for follow-on Core work such as transcription or extraction. Signing is not such work: call `CreateFileSignedUrl` from the client with the `file_uri`, rather than brokering it through a function of your own. This is the shipped default: the app profile reports `core_integrations: backend_functions_only`. Only on an app whose profile reports `client_allowed` may the client call the restricted Core methods directly. + +The `base44-platform-backend-functions` skill (mobile reference) covers file placement, the frontend invocation path and testing; use the Core service-role rule above for the function body. +</core_integrations_backend_functions> + +```js +import { base44 } from "@/api/base44Client"; +// private end-user uploads: +const {file_uri} = await base44.integrations.Core.UploadPrivateFile({file}); +const {signed_url} = await base44.integrations.Core.CreateFileSignedUrl({file_uri}); + +// public end-user uploads: +const {file_url} = await base44.integrations.Core.UploadPublicFile({file}); +``` + +InvokeLLM supports an optional `model` parameter for higher quality output on complex tasks (e.g. model: "claude-sonnet-5"). Non-default models use more integration credits, so only use them when the task requires it. When you use a non-default model, tell the user that it uses more integration credits. Only 'gemini_3_flash' and 'gemini_3_1_pro' support add_context_from_internet. Using an incompatible model will raise an error. + +<end_user_uploads> +Default every end-user upload to `UploadPrivateFile`. Use `UploadPublicFile` only when the user asked for these files to be public, or when something outside the app must fetch the file later without signing in. +</end_user_uploads> + +`SendEmail` reaches registered app users normally. This app may ALSO email addresses that are not registered users, but only with a connected custom domain, on a paid plan, and subject to content screening, recipient-hygiene and anti-abuse limits that can refuse or pause sends — so never promise delivery to a non-registered address, and never route untrusted end-user input straight into the recipient. Invitations still use `base44.users.inviteUser`. + +`SendEmail` accepts an optional `attachments` list: `[{filename, file_url}]` for a file this app uploaded, or `[{filename, content}]` with base64 bytes for a document the function built itself. Max 5 per send, 5 MB each, 10 MB total; documents and images only (pdf, png, jpg, gif, webp, csv, txt, md, ics, xlsx, docx) — archives and executables are refused server-side, as is a payload whose bytes don't match its extension. A `file_url` must be this app's own file. Never derive `attachments` from request input: a caller-chosen file plus a caller-chosen recipient mails the app's own data to a stranger. + +Here are the integration packages available in this app: + +<integration_package> + <package_name>Core</package_name> + <package_description>Built-in integrations that are always available.</package_description> + <endpoints> + + <endpoint> + <name>InvokeLLM</name> + <description> + Generates a response from an LLM with a prompt. + Be elaborate and detailed in your prompt. + You can potentially ask the llm to output a json, in which case specify the json schema in response_json_schema. + If you need to use external knowledge, use add_context_from_internet=true. This will give the LLM context from google search, google maps, news, etc. That way you can fetch structured data from the web. + You can attach files (including images) for additional context to the LLM, using the `file_urls` parameter. This should be a list of urls after uploading them. + You can use both file_urls AND add_context_from_internet together - the system will use a model that supports both vision and web search. + You can optionally specify a `model` for this call. Options: 'automatic', 'gemini_3_8_flash', 'gpt_6_luna', 'claude-sonnet-5', 'claude_opus_5_5', 'gpt_6_sol', 'gpt_6_astra', 'claude_fable_5_1', 'glm_5_2'. Defaults to automatic. + 'gemini_3_8_flash' and 'gpt_6_luna' are fast; 'claude-sonnet-5' is balanced; 'claude_opus_5_5' and 'gpt_6_sol' are frontier models for hard reasoning; 'claude_fable_5_1' and 'gpt_6_astra' are the strongest and by far the most expensive. + All models except 'glm_5_2' support file_urls. Only 'gemini_3_8_flash' supports add_context_from_internet (web search). Using an incompatible model will raise an error. + Non-default models cost more integration credits. Only use them when the task requires higher quality. + If response_json_schema is specified, returns a dict (so no need to parse it), otherwise returns a string. + IMPORTANT: If specified, response_json_schema root object must by of type object, not array. i.e. {"type": "object", ...} + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'add_context_from_internet': {'default': False, 'title': 'Add Context From Internet', 'type': 'boolean'}, 'response_json_schema': {'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 'default': None, 'title': 'Response Json Schema'}, 'file_urls': {'anyOf': [{'format': 'binary', 'type': 'string'}, {'items': {'type': 'string'}, 'type': 'array'}, {'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'File Urls'}, 'model': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Model'}}, 'required': ['prompt'], 'title': 'InvokeLLMModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>TranscribeAudio</name> + <description> + Transcribes an audio file to text using speech recognition (Whisper). + First upload the audio file using the UploadPublicFile integration, then pass the resulting URL here. + Returns the transcript as a plain string. + Supported formats: ogg, oga, mp3, wav, webm, m4a, mp4, mpeg, mpga, flac. Maximum file size: 25MB. + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'audio_url': {'title': 'Audio Url', 'type': 'string'}}, 'required': ['audio_url'], 'title': 'TranscribeAudioModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>SendEmail</name> + <description>Sends email. Reaches registered app users always; reaching an address that is not a registered user requires a paid plan and a custom domain connected to the app. Optionally specify the sender name; otherwise the app name is used.</description> + <input_schema>{'$defs': {'EmailAttachment': {'description': 'One attachment as a caller supplies it: a name plus exactly one source.\n\nNo caller-supplied content type: the extension is already constrained to the\nallowlist, so it determines the type and the two can never disagree.', 'properties': {'filename': {'description': 'Name the recipient sees, with an extension from the supported set: csv, docx, gif, ics, jpeg, jpg, md, pdf, png, txt, webp, xlsx', 'title': 'Filename', 'type': 'string'}, 'file_url': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "URL or storage URI of a file THIS app already uploaded (e.g. the file_url returned by UploadFile). Another app's files are refused.", 'title': 'File Url'}, 'content': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': 'Base64-encoded bytes, for a document the function generated itself. Supply either this or file_url, never both.', 'title': 'Content'}}, 'required': ['filename'], 'title': 'EmailAttachment', 'type': 'object'}}, 'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'from_name': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'From Name'}, 'to': {'title': 'To', 'type': 'string'}, 'subject': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Subject line. Optional with a template: it overrides the template's own <title>.", 'title': 'Subject'}, 'body': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Body'}, 'html': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "HTML body — the same thing as 'body' under an explicit name. Set one or the other, never both.", 'title': 'Html'}, 'text': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Plain-text body. On its own, sends a plain-text email. Alongside 'body'/'html', both go out as one multipart/alternative email and the recipient's mail client renders whichever it prefers — so the two must say the same thing. Not accepted with a template: templates are HTML-only.", 'title': 'Text'}, 'template_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Template Id'}, 'template_name': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Template Name'}, 'variables': {'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 'default': None, 'title': 'Variables'}, 'attachments': {'anyOf': [{'items': {'$ref': '#/$defs/EmailAttachment'}, 'type': 'array'}, {'type': 'null'}], 'default': None, 'title': 'Attachments'}, 'category': {'anyOf': [{'enum': ['marketing', 'transactional'], 'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Category'}}, 'required': ['to'], 'title': 'SendEmailModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>SendPushNotification</name> + <description>Sends a native mobile push notification to one app user's phone. + + Use ONLY when the user explicitly asks for mobile push notifications and the + app ships as a native iOS/Android app. This does not reach the web app, and + it is not email or an in-app notification — for those use SendEmail or build + in-app UI instead. Never reach for it to "notify a user" in general. + + The app must have a native mobile build with push credentials configured; + without that the call fails at send time, so do not build a feature around it + unless the user confirmed they have a native mobile app. + + Server-side only: call it from a backend function via `asServiceRole` (a + logged-in end user must not be able to push to other users). + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'user_id': {'title': 'User Id', 'type': 'string'}, 'title': {'title': 'Title', 'type': 'string'}, 'content': {'title': 'Content', 'type': 'string'}, 'action_label': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Action Label'}, 'action_url': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Action Url'}}, 'required': ['user_id', 'title', 'content'], 'title': 'SendPushNotificationModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>UploadPublicFile</name> + <description> + Uploads a file to permanent, world-readable public storage. + Returns a dict containing {"file_url": string} + Anyone holding the url can fetch the file without signing in. + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file': {'format': 'binary', 'title': 'File', 'type': 'string'}}, 'required': ['file'], 'title': 'UploadPublicFileModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>GenerateImage</name> + <description>Generate an image using AI. + Takes about 5-10 seconds to generate an image. + Be detailed in the prompt, the better the prompt, the better the image. + Optionally provide existing_image_urls as reference - for editing, style inspiration, or visual context. + The integration returns a URL to the generated image: {url: str}</description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'existing_image_urls': {'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}], 'default': None, 'title': 'Existing Image Urls'}}, 'required': ['prompt'], 'title': 'GenerateImageModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>GenerateSpeech</name> + <description>Generate stored, shareable speech audio (TTS) from text. Returns an MP3 URL: {url: str}. + Multiple voices, consistent across users, high-quality multilingual (auto-detected if language_code is omitted). + For simple in-page playback, prefer the browser's TTS API. + Cost: 1 integration credit per 50 characters of text, up to 100 per call.</description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'text': {'description': 'Text to convert. Max 5000 chars.', 'title': 'Text', 'type': 'string'}, 'voice': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Optional voice (default 'river'):\n- river: calm, neutral.\n- honey: warm, soft.\n- sunny: bright, upbeat.\n- storm: formal, authoritative.\n- spark: energetic, quick.", 'title': 'Voice'}, 'language_code': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Optional ISO-639-1 code (e.g. 'en', 'es'). Omit to auto-detect.", 'title': 'Language Code'}}, 'required': ['text'], 'title': 'GenerateSpeechModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>GenerateVideo</name> + <description>Generate a video using AI. + + Takes about 30-60 seconds to generate a video using Google's Veo 3.x models. + Be detailed in the prompt - the better the prompt, the better the video. + + The integration returns a URL to the generated video: {url: str} + + Credit Cost: 5 integration credits per second of video + - 4 seconds = 20 credits + - 6 seconds (default) = 30 credits + - 8 seconds = 40 credits + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'duration': {'default': 6, 'enum': [4, 6, 8], 'title': 'Duration', 'type': 'integer'}, 'aspect_ratio': {'default': '16:9', 'enum': ['16:9', '9:16'], 'title': 'Aspect Ratio', 'type': 'string'}, 'generate_audio': {'default': True, 'title': 'Generate Audio', 'type': 'boolean'}}, 'required': ['prompt'], 'title': 'GenerateVideoModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>ExtractDataFromUploadedFile</name> + <description> + Extracts data from an uploaded file. + Use the UploadPublicFile integration to get the file url first. + Supports csv, xlsx, json, html, png, jpg, jpeg, and pdf files. + Useful to extract data, and potential flow - upload file, extract data, bulk insert entities into database. + Only use this integration when you know the target JSON schema of the data you're trying to extract (if you just want the content of a file - fetch the file url). + returns the following object: + { + "status": "success" | "error", + "details": "error details if error, otherwise null", + "output": list[dict] | dict | None (exists if status is success) + } + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file_url': {'title': 'File Url', 'type': 'string'}, 'json_schema': {'additionalProperties': True, 'title': 'Json Schema', 'type': 'object'}}, 'required': ['file_url', 'json_schema'], 'title': 'ExtractDataFromUploadedFileModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>CreateFileSignedUrl</name> + <description> + Creates a signed URL for a private file that can be used to download the file. + The file must be uploaded to the private storage. + Returns a dict containing {"signed_url": string} + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file_uri': {'title': 'File Uri', 'type': 'string'}, 'expires_in': {'default': 300, 'title': 'Expires In', 'type': 'integer'}}, 'required': ['file_uri'], 'title': 'CreateFileSignedUrlModel', 'type': 'object'}</input_schema> + </endpoint> + + <endpoint> + <name>UploadPrivateFile</name> + <description> + Uploads a file to the app's private filhes directory. + Returns a dict containing {"file_uri": string} + You can use the file_uri later to create a signed URL for the file using the CreateFileSignedUrl integration + The file is uploaded to the private storage. + </description> + <input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file': {'format': 'binary', 'title': 'File', 'type': 'string'}}, 'required': ['file'], 'title': 'UploadPrivateFileModel', 'type': 'object'}</input_schema> + </endpoint> + + </endpoints> +</integration_package> diff --git a/skills/base44-platform-integrations-sdk/references/web-spa.md b/skills/base44-platform-integrations-sdk/references/web-spa.md new file mode 100644 index 0000000..8938252 --- /dev/null +++ b/skills/base44-platform-integrations-sdk/references/web-spa.md @@ -0,0 +1,188 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 integrations sdk: Integrations and analytics SDK (Vite SPA) + +Integrations: function packages for external data/services, on the pre-initialized base44 client: + +<core_integrations_backend_functions> +All `base44.integrations.Core` SDK methods except `UploadPublicFile`, `UploadPrivateFile` and `CreateFileSignedUrl` must run inside a backend function using `base44.asServiceRole.integrations.Core`. Check every Core Integration call in the client code. The restricted methods cannot remain there. `UploadFile` is the retired spelling of `UploadPublicFile` and is equally client-callable. + +Use a clearly named, narrow app operation, not a 1:1 Core proxy: hardcode the Core method, expose and validate only the bounded inputs the feature needs, keep privileged configuration server-side, and return only the required result. Never accept an integration name plus an arbitrary payload for forwarding. +Limit each request's cost and scope (for example prompt/file size, item count, output count, or fixed recipients/destinations) and add feature-appropriate abuse controls when available. + +Upload files directly from the client with `UploadPublicFile` or `UploadPrivateFile`. Pass the resulting `file_url` or `file_uri` to a backend function for follow-on Core work such as transcription or extraction. Signing is not such work: call `CreateFileSignedUrl` from the client with the `file_uri`, rather than brokering it through a function of your own. This is the shipped default: the app profile reports `core_integrations: backend_functions_only`. Only on an app whose profile reports `client_allowed` may the client call the restricted Core methods directly. + +The `base44-platform-backend-functions` skill covers file placement, the frontend invocation path and testing; use the Core service-role rule above for the function body. +</core_integrations_backend_functions> + +```js +// private end-user uploads: +const {file_uri} = await base44.integrations.Core.UploadPrivateFile({file}); +const {signed_url} = await base44.integrations.Core.CreateFileSignedUrl({file_uri}); + +// public end-user uploads: +const {file_url} = await base44.integrations.Core.UploadPublicFile({file}); +``` + +<end_user_uploads> +Default every end-user upload to `UploadPrivateFile`. Use `UploadPublicFile` only when the user asked for these files to be public, or when something outside the app must fetch the file later without signing in. +</end_user_uploads> +InvokeLLM optional `model` (e.g. model: "claude-sonnet-5") for complex tasks — non-default models use more integration credits: only when needed, and tell the user. add_context_from_internet only with 'gemini_3_flash'/'gemini_3_1_pro'; others raise an error. InvokeLLM is a single call with no tools — don't chain it to simulate an agent loop; agents are covered in the agents guide. + +`SendEmail` reaches registered app users normally. This app may ALSO email addresses that are not registered users, but only with a connected custom domain, on a paid plan, and subject to content screening, recipient-hygiene and anti-abuse limits that can refuse or pause sends — so never promise delivery to a non-registered address, and never route untrusted end-user input straight into the recipient. Invitations still use `base44.users.inviteUser`. + +`SendEmail` optional `attachments`: `[{filename, file_url}]` for a file this app uploaded, or `[{filename, content}]` with base64 for one the function built. Max 5 per send, 5 MB each, 10 MB total; documents and images only (pdf, png, jpg, gif, webp, csv, txt, md, ics, xlsx, docx) — archives and executables are refused server-side, as is a payload whose bytes don't match its extension. `file_url` must be this app's own file. Never derive `attachments` from request input: a caller-chosen file plus a caller-chosen recipient mails the app's own data to a stranger. + +Integration packages in this app: + +<integration_package> +<package_name>Core</package_name> +<package_description>Built-in integrations that are always available.</package_description> + +<endpoint> +<name>InvokeLLM</name> +<description> + Generates a response from an LLM with a prompt. + Be elaborate and detailed in your prompt. + You can potentially ask the llm to output a json, in which case specify the json schema in response_json_schema. + If you need to use external knowledge, use add_context_from_internet=true. This will give the LLM context from google search, google maps, news, etc. That way you can fetch structured data from the web. + You can attach files (including images) for additional context to the LLM, using the `file_urls` parameter. This should be a list of urls after uploading them. + You can use both file_urls AND add_context_from_internet together - the system will use a model that supports both vision and web search. + You can optionally specify a `model` for this call. Options: 'automatic', 'gemini_3_8_flash', 'gpt_6_luna', 'claude-sonnet-5', 'claude_opus_5_5', 'gpt_6_sol', 'gpt_6_astra', 'claude_fable_5_1', 'glm_5_2'. Defaults to automatic. + 'gemini_3_8_flash' and 'gpt_6_luna' are fast; 'claude-sonnet-5' is balanced; 'claude_opus_5_5' and 'gpt_6_sol' are frontier models for hard reasoning; 'claude_fable_5_1' and 'gpt_6_astra' are the strongest and by far the most expensive. + All models except 'glm_5_2' support file_urls. Only 'gemini_3_8_flash' supports add_context_from_internet (web search). Using an incompatible model will raise an error. + Non-default models cost more integration credits. Only use them when the task requires higher quality. + If response_json_schema is specified, returns a dict (so no need to parse it), otherwise returns a string. + IMPORTANT: If specified, response_json_schema root object must by of type object, not array. i.e. {"type": "object", ...} + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'add_context_from_internet': {'default': False, 'title': 'Add Context From Internet', 'type': 'boolean'}, 'response_json_schema': {'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 'default': None, 'title': 'Response Json Schema'}, 'file_urls': {'anyOf': [{'format': 'binary', 'type': 'string'}, {'items': {'type': 'string'}, 'type': 'array'}, {'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'File Urls'}, 'model': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Model'}}, 'required': ['prompt'], 'title': 'InvokeLLMModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>TranscribeAudio</name> +<description> + Transcribes an audio file to text using speech recognition (Whisper). + First upload the audio file using the UploadPublicFile integration, then pass the resulting URL here. + Returns the transcript as a plain string. + Supported formats: ogg, oga, mp3, wav, webm, m4a, mp4, mpeg, mpga, flac. Maximum file size: 25MB. + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'audio_url': {'title': 'Audio Url', 'type': 'string'}}, 'required': ['audio_url'], 'title': 'TranscribeAudioModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>SendEmail</name> +<description>Sends email. Reaches registered app users always; reaching an address that is not a registered user requires a paid plan and a custom domain connected to the app. Optionally specify the sender name; otherwise the app name is used.</description> +<input_schema>{'$defs': {'EmailAttachment': {'description': 'One attachment as a caller supplies it: a name plus exactly one source.\n\nNo caller-supplied content type: the extension is already constrained to the\nallowlist, so it determines the type and the two can never disagree.', 'properties': {'filename': {'description': 'Name the recipient sees, with an extension from the supported set: csv, docx, gif, ics, jpeg, jpg, md, pdf, png, txt, webp, xlsx', 'title': 'Filename', 'type': 'string'}, 'file_url': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "URL or storage URI of a file THIS app already uploaded (e.g. the file_url returned by UploadFile). Another app's files are refused.", 'title': 'File Url'}, 'content': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': 'Base64-encoded bytes, for a document the function generated itself. Supply either this or file_url, never both.', 'title': 'Content'}}, 'required': ['filename'], 'title': 'EmailAttachment', 'type': 'object'}}, 'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'from_name': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'From Name'}, 'to': {'title': 'To', 'type': 'string'}, 'subject': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Subject line. Optional with a template: it overrides the template's own <title>.", 'title': 'Subject'}, 'body': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Body'}, 'html': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "HTML body — the same thing as 'body' under an explicit name. Set one or the other, never both.", 'title': 'Html'}, 'text': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Plain-text body. On its own, sends a plain-text email. Alongside 'body'/'html', both go out as one multipart/alternative email and the recipient's mail client renders whichever it prefers — so the two must say the same thing. Not accepted with a template: templates are HTML-only.", 'title': 'Text'}, 'template_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Template Id'}, 'template_name': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Template Name'}, 'variables': {'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 'default': None, 'title': 'Variables'}, 'attachments': {'anyOf': [{'items': {'$ref': '#/$defs/EmailAttachment'}, 'type': 'array'}, {'type': 'null'}], 'default': None, 'title': 'Attachments'}, 'category': {'anyOf': [{'enum': ['marketing', 'transactional'], 'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Category'}}, 'required': ['to'], 'title': 'SendEmailModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>SendPushNotification</name> +<description>Sends a native mobile push notification to one app user's phone. + + Use ONLY when the user explicitly asks for mobile push notifications and the + app ships as a native iOS/Android app. This does not reach the web app, and + it is not email or an in-app notification — for those use SendEmail or build + in-app UI instead. Never reach for it to "notify a user" in general. + + The app must have a native mobile build with push credentials configured; + without that the call fails at send time, so do not build a feature around it + unless the user confirmed they have a native mobile app. + + Server-side only: call it from a backend function via `asServiceRole` (a + logged-in end user must not be able to push to other users). + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'user_id': {'title': 'User Id', 'type': 'string'}, 'title': {'title': 'Title', 'type': 'string'}, 'content': {'title': 'Content', 'type': 'string'}, 'action_label': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Action Label'}, 'action_url': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Action Url'}}, 'required': ['user_id', 'title', 'content'], 'title': 'SendPushNotificationModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>UploadPublicFile</name> +<description> + Uploads a file to permanent, world-readable public storage. + Returns a dict containing {"file_url": string} + Anyone holding the url can fetch the file without signing in. + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file': {'format': 'binary', 'title': 'File', 'type': 'string'}}, 'required': ['file'], 'title': 'UploadPublicFileModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>GenerateImage</name> +<description>Generate an image using AI. + Takes about 5-10 seconds to generate an image. + Be detailed in the prompt, the better the prompt, the better the image. + Optionally provide existing_image_urls as reference - for editing, style inspiration, or visual context. + The integration returns a URL to the generated image: {url: str}</description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'existing_image_urls': {'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}], 'default': None, 'title': 'Existing Image Urls'}}, 'required': ['prompt'], 'title': 'GenerateImageModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>GenerateSpeech</name> +<description>Generate stored, shareable speech audio (TTS) from text. Returns an MP3 URL: {url: str}. + Multiple voices, consistent across users, high-quality multilingual (auto-detected if language_code is omitted). + For simple in-page playback, prefer the browser's TTS API. + Cost: 1 integration credit per 50 characters of text, up to 100 per call.</description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'text': {'description': 'Text to convert. Max 5000 chars.', 'title': 'Text', 'type': 'string'}, 'voice': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Optional voice (default 'river'):\n- river: calm, neutral.\n- honey: warm, soft.\n- sunny: bright, upbeat.\n- storm: formal, authoritative.\n- spark: energetic, quick.", 'title': 'Voice'}, 'language_code': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Optional ISO-639-1 code (e.g. 'en', 'es'). Omit to auto-detect.", 'title': 'Language Code'}}, 'required': ['text'], 'title': 'GenerateSpeechModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>GenerateVideo</name> +<description>Generate a video using AI. + + Takes about 30-60 seconds to generate a video using Google's Veo 3.x models. + Be detailed in the prompt - the better the prompt, the better the video. + + The integration returns a URL to the generated video: {url: str} + + Credit Cost: 5 integration credits per second of video + - 4 seconds = 20 credits + - 6 seconds (default) = 30 credits + - 8 seconds = 40 credits + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'duration': {'default': 6, 'enum': [4, 6, 8], 'title': 'Duration', 'type': 'integer'}, 'aspect_ratio': {'default': '16:9', 'enum': ['16:9', '9:16'], 'title': 'Aspect Ratio', 'type': 'string'}, 'generate_audio': {'default': True, 'title': 'Generate Audio', 'type': 'boolean'}}, 'required': ['prompt'], 'title': 'GenerateVideoModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>ExtractDataFromUploadedFile</name> +<description> + Extracts data from an uploaded file. + Use the UploadPublicFile integration to get the file url first. + Supports csv, xlsx, json, html, png, jpg, jpeg, and pdf files. + Useful to extract data, and potential flow - upload file, extract data, bulk insert entities into database. + Only use this integration when you know the target JSON schema of the data you're trying to extract (if you just want the content of a file - fetch the file url). + returns the following object: + { + "status": "success" | "error", + "details": "error details if error, otherwise null", + "output": list[dict] | dict | None (exists if status is success) + } + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file_url': {'title': 'File Url', 'type': 'string'}, 'json_schema': {'additionalProperties': True, 'title': 'Json Schema', 'type': 'object'}}, 'required': ['file_url', 'json_schema'], 'title': 'ExtractDataFromUploadedFileModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>CreateFileSignedUrl</name> +<description> + Creates a signed URL for a private file that can be used to download the file. + The file must be uploaded to the private storage. + Returns a dict containing {"signed_url": string} + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file_uri': {'title': 'File Uri', 'type': 'string'}, 'expires_in': {'default': 300, 'title': 'Expires In', 'type': 'integer'}}, 'required': ['file_uri'], 'title': 'CreateFileSignedUrlModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>UploadPrivateFile</name> +<description> + Uploads a file to the app's private filhes directory. + Returns a dict containing {"file_uri": string} + You can use the file_uri later to create a signed URL for the file using the CreateFileSignedUrl integration + The file is uploaded to the private storage. + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file': {'format': 'binary', 'title': 'File', 'type': 'string'}}, 'required': ['file'], 'title': 'UploadPrivateFileModel', 'type': 'object'}</input_schema> +</endpoint> + +</integration_package> + +Track custom events with base44 from '@/api/base44Client': +base44.analytics.track({ eventName: string, properties?: Record<string, string | number | boolean | null> }) +Indicative event names (e.g. "user_contact_form_submit"), minimal properties, no PII. +To opt-out, set `{"analytics":{"enabled":false}}` when initializing the base44 SDK (supported from 0.8.46). diff --git a/skills/base44-platform-integrations-sdk/references/web-tanstack.md b/skills/base44-platform-integrations-sdk/references/web-tanstack.md new file mode 100644 index 0000000..300c268 --- /dev/null +++ b/skills/base44-platform-integrations-sdk/references/web-tanstack.md @@ -0,0 +1,186 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 integrations sdk: Integrations and analytics SDK (TanStack Start) + +Integrations: function packages for external data/services, on the pre-initialized base44 client: + +<core_integrations_backend_functions> +All `base44.integrations.Core` SDK methods except `UploadPublicFile`, `UploadPrivateFile` and `CreateFileSignedUrl` must run inside a server function using `context.getBase44().asServiceRole.integrations.Core`. Check every Core Integration call in the client code. The restricted methods cannot remain there. `UploadFile` is the retired spelling of `UploadPublicFile` and is equally client-callable. + +Use a clearly named, narrow app operation, not a 1:1 Core proxy: hardcode the Core method, expose and validate only the bounded inputs the feature needs, keep privileged configuration server-side, and return only the required result. Never accept an integration name plus an arbitrary payload for forwarding. +Limit each request's cost and scope (for example prompt/file size, item count, output count, or fixed recipients/destinations) and add feature-appropriate abuse controls when available. + +Upload files directly from the client with `UploadPublicFile` or `UploadPrivateFile`. Pass the resulting `file_url` or `file_uri` to a server function for follow-on Core work such as transcription or extraction. Signing is not such work: call `CreateFileSignedUrl` from the client with the `file_uri`, rather than brokering it through a function of your own. This is the shipped default: the app profile reports `core_integrations: backend_functions_only`. Only on an app whose profile reports `client_allowed` may the client call the restricted Core methods directly. +</core_integrations_backend_functions> + +```js +// private end-user uploads: +const {file_uri} = await base44.integrations.Core.UploadPrivateFile({file}); +const {signed_url} = await base44.integrations.Core.CreateFileSignedUrl({file_uri}); + +// public end-user uploads: +const {file_url} = await base44.integrations.Core.UploadPublicFile({file}); +``` + +<end_user_uploads> +Default every end-user upload to `UploadPrivateFile`. Use `UploadPublicFile` only when the user asked for these files to be public, or when something outside the app must fetch the file later without signing in. +</end_user_uploads> +InvokeLLM optional `model` (e.g. model: "claude-sonnet-5") for complex tasks — non-default models use more integration credits: only when needed, and tell the user. add_context_from_internet only with 'gemini_3_flash'/'gemini_3_1_pro'; others raise an error. InvokeLLM is a single call with no tools — don't chain it to simulate an agent loop; agents are covered in the agents guide. + +`SendEmail` reaches registered app users normally. This app may ALSO email addresses that are not registered users, but only with a connected custom domain, on a paid plan, and subject to content screening, recipient-hygiene and anti-abuse limits that can refuse or pause sends — so never promise delivery to a non-registered address, and never route untrusted end-user input straight into the recipient. Invitations still use `base44.users.inviteUser`. + +`SendEmail` optional `attachments`: `[{filename, file_url}]` for a file this app uploaded, or `[{filename, content}]` with base64 for one the function built. Max 5 per send, 5 MB each, 10 MB total; documents and images only (pdf, png, jpg, gif, webp, csv, txt, md, ics, xlsx, docx) — archives and executables are refused server-side, as is a payload whose bytes don't match its extension. `file_url` must be this app's own file. Never derive `attachments` from request input: a caller-chosen file plus a caller-chosen recipient mails the app's own data to a stranger. + +Integration packages in this app: + +<integration_package> +<package_name>Core</package_name> +<package_description>Built-in integrations that are always available.</package_description> + +<endpoint> +<name>InvokeLLM</name> +<description> + Generates a response from an LLM with a prompt. + Be elaborate and detailed in your prompt. + You can potentially ask the llm to output a json, in which case specify the json schema in response_json_schema. + If you need to use external knowledge, use add_context_from_internet=true. This will give the LLM context from google search, google maps, news, etc. That way you can fetch structured data from the web. + You can attach files (including images) for additional context to the LLM, using the `file_urls` parameter. This should be a list of urls after uploading them. + You can use both file_urls AND add_context_from_internet together - the system will use a model that supports both vision and web search. + You can optionally specify a `model` for this call. Options: 'automatic', 'gemini_3_8_flash', 'gpt_6_luna', 'claude-sonnet-5', 'claude_opus_5_5', 'gpt_6_sol', 'gpt_6_astra', 'claude_fable_5_1', 'glm_5_2'. Defaults to automatic. + 'gemini_3_8_flash' and 'gpt_6_luna' are fast; 'claude-sonnet-5' is balanced; 'claude_opus_5_5' and 'gpt_6_sol' are frontier models for hard reasoning; 'claude_fable_5_1' and 'gpt_6_astra' are the strongest and by far the most expensive. + All models except 'glm_5_2' support file_urls. Only 'gemini_3_8_flash' supports add_context_from_internet (web search). Using an incompatible model will raise an error. + Non-default models cost more integration credits. Only use them when the task requires higher quality. + If response_json_schema is specified, returns a dict (so no need to parse it), otherwise returns a string. + IMPORTANT: If specified, response_json_schema root object must by of type object, not array. i.e. {"type": "object", ...} + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'add_context_from_internet': {'default': False, 'title': 'Add Context From Internet', 'type': 'boolean'}, 'response_json_schema': {'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 'default': None, 'title': 'Response Json Schema'}, 'file_urls': {'anyOf': [{'format': 'binary', 'type': 'string'}, {'items': {'type': 'string'}, 'type': 'array'}, {'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'File Urls'}, 'model': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Model'}}, 'required': ['prompt'], 'title': 'InvokeLLMModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>TranscribeAudio</name> +<description> + Transcribes an audio file to text using speech recognition (Whisper). + First upload the audio file using the UploadPublicFile integration, then pass the resulting URL here. + Returns the transcript as a plain string. + Supported formats: ogg, oga, mp3, wav, webm, m4a, mp4, mpeg, mpga, flac. Maximum file size: 25MB. + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'audio_url': {'title': 'Audio Url', 'type': 'string'}}, 'required': ['audio_url'], 'title': 'TranscribeAudioModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>SendEmail</name> +<description>Sends email. Reaches registered app users always; reaching an address that is not a registered user requires a paid plan and a custom domain connected to the app. Optionally specify the sender name; otherwise the app name is used.</description> +<input_schema>{'$defs': {'EmailAttachment': {'description': 'One attachment as a caller supplies it: a name plus exactly one source.\n\nNo caller-supplied content type: the extension is already constrained to the\nallowlist, so it determines the type and the two can never disagree.', 'properties': {'filename': {'description': 'Name the recipient sees, with an extension from the supported set: csv, docx, gif, ics, jpeg, jpg, md, pdf, png, txt, webp, xlsx', 'title': 'Filename', 'type': 'string'}, 'file_url': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "URL or storage URI of a file THIS app already uploaded (e.g. the file_url returned by UploadFile). Another app's files are refused.", 'title': 'File Url'}, 'content': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': 'Base64-encoded bytes, for a document the function generated itself. Supply either this or file_url, never both.', 'title': 'Content'}}, 'required': ['filename'], 'title': 'EmailAttachment', 'type': 'object'}}, 'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'from_name': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'From Name'}, 'to': {'title': 'To', 'type': 'string'}, 'subject': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Subject line. Optional with a template: it overrides the template's own <title>.", 'title': 'Subject'}, 'body': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Body'}, 'html': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "HTML body — the same thing as 'body' under an explicit name. Set one or the other, never both.", 'title': 'Html'}, 'text': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Plain-text body. On its own, sends a plain-text email. Alongside 'body'/'html', both go out as one multipart/alternative email and the recipient's mail client renders whichever it prefers — so the two must say the same thing. Not accepted with a template: templates are HTML-only.", 'title': 'Text'}, 'template_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Template Id'}, 'template_name': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Template Name'}, 'variables': {'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}], 'default': None, 'title': 'Variables'}, 'attachments': {'anyOf': [{'items': {'$ref': '#/$defs/EmailAttachment'}, 'type': 'array'}, {'type': 'null'}], 'default': None, 'title': 'Attachments'}, 'category': {'anyOf': [{'enum': ['marketing', 'transactional'], 'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Category'}}, 'required': ['to'], 'title': 'SendEmailModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>SendPushNotification</name> +<description>Sends a native mobile push notification to one app user's phone. + + Use ONLY when the user explicitly asks for mobile push notifications and the + app ships as a native iOS/Android app. This does not reach the web app, and + it is not email or an in-app notification — for those use SendEmail or build + in-app UI instead. Never reach for it to "notify a user" in general. + + The app must have a native mobile build with push credentials configured; + without that the call fails at send time, so do not build a feature around it + unless the user confirmed they have a native mobile app. + + Server-side only: call it from a backend function via `asServiceRole` (a + logged-in end user must not be able to push to other users). + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'user_id': {'title': 'User Id', 'type': 'string'}, 'title': {'title': 'Title', 'type': 'string'}, 'content': {'title': 'Content', 'type': 'string'}, 'action_label': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Action Label'}, 'action_url': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Action Url'}}, 'required': ['user_id', 'title', 'content'], 'title': 'SendPushNotificationModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>UploadPublicFile</name> +<description> + Uploads a file to permanent, world-readable public storage. + Returns a dict containing {"file_url": string} + Anyone holding the url can fetch the file without signing in. + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file': {'format': 'binary', 'title': 'File', 'type': 'string'}}, 'required': ['file'], 'title': 'UploadPublicFileModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>GenerateImage</name> +<description>Generate an image using AI. + Takes about 5-10 seconds to generate an image. + Be detailed in the prompt, the better the prompt, the better the image. + Optionally provide existing_image_urls as reference - for editing, style inspiration, or visual context. + The integration returns a URL to the generated image: {url: str}</description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'existing_image_urls': {'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}], 'default': None, 'title': 'Existing Image Urls'}}, 'required': ['prompt'], 'title': 'GenerateImageModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>GenerateSpeech</name> +<description>Generate stored, shareable speech audio (TTS) from text. Returns an MP3 URL: {url: str}. + Multiple voices, consistent across users, high-quality multilingual (auto-detected if language_code is omitted). + For simple in-page playback, prefer the browser's TTS API. + Cost: 1 integration credit per 50 characters of text, up to 100 per call.</description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'text': {'description': 'Text to convert. Max 5000 chars.', 'title': 'Text', 'type': 'string'}, 'voice': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Optional voice (default 'river'):\n- river: calm, neutral.\n- honey: warm, soft.\n- sunny: bright, upbeat.\n- storm: formal, authoritative.\n- spark: energetic, quick.", 'title': 'Voice'}, 'language_code': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'description': "Optional ISO-639-1 code (e.g. 'en', 'es'). Omit to auto-detect.", 'title': 'Language Code'}}, 'required': ['text'], 'title': 'GenerateSpeechModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>GenerateVideo</name> +<description>Generate a video using AI. + + Takes about 30-60 seconds to generate a video using Google's Veo 3.x models. + Be detailed in the prompt - the better the prompt, the better the video. + + The integration returns a URL to the generated video: {url: str} + + Credit Cost: 5 integration credits per second of video + - 4 seconds = 20 credits + - 6 seconds (default) = 30 credits + - 8 seconds = 40 credits + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'prompt': {'title': 'Prompt', 'type': 'string'}, 'duration': {'default': 6, 'enum': [4, 6, 8], 'title': 'Duration', 'type': 'integer'}, 'aspect_ratio': {'default': '16:9', 'enum': ['16:9', '9:16'], 'title': 'Aspect Ratio', 'type': 'string'}, 'generate_audio': {'default': True, 'title': 'Generate Audio', 'type': 'boolean'}}, 'required': ['prompt'], 'title': 'GenerateVideoModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>ExtractDataFromUploadedFile</name> +<description> + Extracts data from an uploaded file. + Use the UploadPublicFile integration to get the file url first. + Supports csv, xlsx, json, html, png, jpg, jpeg, and pdf files. + Useful to extract data, and potential flow - upload file, extract data, bulk insert entities into database. + Only use this integration when you know the target JSON schema of the data you're trying to extract (if you just want the content of a file - fetch the file url). + returns the following object: + { + "status": "success" | "error", + "details": "error details if error, otherwise null", + "output": list[dict] | dict | None (exists if status is success) + } + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file_url': {'title': 'File Url', 'type': 'string'}, 'json_schema': {'additionalProperties': True, 'title': 'Json Schema', 'type': 'object'}}, 'required': ['file_url', 'json_schema'], 'title': 'ExtractDataFromUploadedFileModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>CreateFileSignedUrl</name> +<description> + Creates a signed URL for a private file that can be used to download the file. + The file must be uploaded to the private storage. + Returns a dict containing {"signed_url": string} + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file_uri': {'title': 'File Uri', 'type': 'string'}, 'expires_in': {'default': 300, 'title': 'Expires In', 'type': 'integer'}}, 'required': ['file_uri'], 'title': 'CreateFileSignedUrlModel', 'type': 'object'}</input_schema> +</endpoint> + +<endpoint> +<name>UploadPrivateFile</name> +<description> + Uploads a file to the app's private filhes directory. + Returns a dict containing {"file_uri": string} + You can use the file_uri later to create a signed URL for the file using the CreateFileSignedUrl integration + The file is uploaded to the private storage. + </description> +<input_schema>{'properties': {'app_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Id'}, 'app_owner': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'App Owner'}, 'file': {'format': 'binary', 'title': 'File', 'type': 'string'}}, 'required': ['file'], 'title': 'UploadPrivateFileModel', 'type': 'object'}</input_schema> +</endpoint> + +</integration_package> + +Track custom events with base44 from '@/api/base44Client': +base44.analytics.track({ eventName: string, properties?: Record<string, string | number | boolean | null> }) +Indicative event names (e.g. "user_contact_form_submit"), minimal properties, no PII. +To opt-out, set `{"analytics":{"enabled":false}}` when initializing the base44 SDK (supported from 0.8.46). diff --git a/skills/base44-platform-manifest.json b/skills/base44-platform-manifest.json new file mode 100644 index 0000000..c23e920 --- /dev/null +++ b/skills/base44-platform-manifest.json @@ -0,0 +1,39 @@ +{ + "files": { + "skills/base44-platform-actors/SKILL.md": "52d3c0959e896cbbaeff58ce43c28687bef630de1c164dafb21d6dcab3086321", + "skills/base44-platform-actors/references/mobile.md": "31cc9bf4dcb7aacc9e8f98df8020c86130d0c14f719518945d6613d29798dbfe", + "skills/base44-platform-actors/references/web.md": "de7d5e7aea53b8a9e1f4eb89a75298d49a72da1b923215e87779926c6b7cf655", + "skills/base44-platform-agents/SKILL.md": "1d6f55d6bd71449d629b2662c647e8e242bdb0ab6432c33d34534437241888ab", + "skills/base44-platform-agents/references/web-spa.md": "25e0031b1556fc6ddfa9871f472a82acf6460033ccec0510090cd70343eb8c73", + "skills/base44-platform-agents/references/web-tanstack.md": "94c1b47f873f44daebdb46cbad6931e4ac769dd1669a237aa36e808154f8e0d9", + "skills/base44-platform-backend-functions/SKILL.md": "146505a402ba4ba478f4042dfbf2e6d46e3497fa73e9102ab8404c76224a3376", + "skills/base44-platform-backend-functions/references/mobile.md": "d9dbe5d90c1c00b126bf4981605edefd1acac9308c0e665fc1b1d4dae17782af", + "skills/base44-platform-backend-functions/references/web-spa.md": "777b39518159c3241fc385b6506debdf2d3486d69a1d131fe2df615ce366dc6f", + "skills/base44-platform-backend-functions/references/web-tanstack.md": "966a9b0495d78b44acf64eca0bd00bc1def50258dc71389c752ee62ede80a697", + "skills/base44-platform-connectors/SKILL.md": "e037d9536b1d8267b85a7d25ab0416c1a8fb0f88763e2365ebae97cec23a4bea", + "skills/base44-platform-connectors/references/mobile.md": "0b06a72e4152209a652dbeeeacf9cc2577391851e9a7437deb6de8b698c48ddb", + "skills/base44-platform-connectors/references/web-spa.md": "cd74ec5f30b0f50adc06328b5f7514c59dc01c277de57835473323535a6ffb9d", + "skills/base44-platform-connectors/references/web-tanstack.md": "5e40af077a22f09d040cb64a3069c52d0b463cc0b6594b0d5effb3c8944c63c9", + "skills/base44-platform-entities/SKILL.md": "9b37a2aff19212a88aa0c14b113a3246c9ae714a8b45247f0c2e7c251d0c9466", + "skills/base44-platform-entities/references/mobile.md": "cbee462933515db5329f9901ff401d666f9084b7bb1a5ca91c90be6b33101240", + "skills/base44-platform-entities/references/rls.md": "a87a71751bb63c28a0629c2ca65019c726c3b549149a029f74f5a84cb86b8dcf", + "skills/base44-platform-entities/references/web.md": "e30672b094b3b5ed2fc926b06ee3646a8f1777491cae41cfb9a0be65f8bb1a9b", + "skills/base44-platform-frontend/SKILL.md": "0b06d58d67108e260d147d1ced4143755a451823e59e6a3012cfd71556921ac7", + "skills/base44-platform-frontend/references/mobile.md": "ecf9499821d8cf345e8b32143f2d0f3e72a4daeda06588a251d26f546bcac60a", + "skills/base44-platform-frontend/references/web-spa.md": "56ce2d10899f72cc50e2ed0f55a60520d96ce7e897b7428bc640c3790ab34e46", + "skills/base44-platform-frontend/references/web-tanstack.md": "6aa5a8dc515553055d29bb33a663731bf8d3465fd3a717ce458ca874a243ab9a", + "skills/base44-platform-integrations-sdk/SKILL.md": "cc18027267ab6f7c64d34e8d26d3aa4b3fcfe2d6c35b2c81468a2351c6658997", + "skills/base44-platform-integrations-sdk/references/mobile.md": "8dfc2de889e3a54e1c0de4dd32daf83b3dee5984d23a04d11c491a0b83807a1c", + "skills/base44-platform-integrations-sdk/references/web-spa.md": "5963ff6747e9a8b5224e448c1da76d54da9fb42f6344c044ed25c973daadf950", + "skills/base44-platform-integrations-sdk/references/web-tanstack.md": "d0a361ac1d5dcca09ce3e87725c86526181ad3f9587e6ea0f71136fc905657ed", + "skills/base44-platform-platform/SKILL.md": "cdcec44e4885c6970cc6f79880194fbe7dd99c2aedad9480d4afca18ce339a19", + "skills/base44-platform-platform/references/mobile.md": "bc5116e7e7896f56908dd8e400cb7b7101f0ee53597725a0af614002b1ac669a", + "skills/base44-platform-platform/references/web-spa.md": "52917f4b90d778d21e73b70ab7862e9250044831b51db4dd8723cf68a6706941", + "skills/base44-platform-platform/references/web-tanstack.md": "625c2c6f75647c843292b9a864db8b737b674848cae1d9e44e9630185c8429d9", + "skills/base44-platform-workflows/SKILL.md": "fbe451b408f73831a830094c3968ae15ef27f846138808c54039ef24f7dc56c4", + "skills/base44-platform-workflows/references/automations-legacy.md": "4a4d8932a4f544a29260e4095eb7e98cb68b5dd229c7f9066579fdce7bbf9ae3", + "skills/base44-platform-workflows/references/backend-functions.md": "92307752be105d6db7e13576740785281e1812dec508f66a2b530312973b71f3", + "skills/base44-platform-workflows/references/mobile-automations.md": "20e412446eb1190ae33837799e31090ad2cf3b254a2675ca7ed22ff9cc4ac907", + "skills/base44-platform-workflows/references/web-tanstack.md": "679d1a60b5854952426f08f71001dd8e962ffd1a15e2c514a2f88c4045b848f7" + } +} diff --git a/skills/base44-platform-platform/SKILL.md b/skills/base44-platform-platform/SKILL.md new file mode 100644 index 0000000..87f21d4 --- /dev/null +++ b/skills/base44-platform-platform/SKILL.md @@ -0,0 +1,24 @@ +--- +name: base44-platform-platform +description: "Base44 platform concepts for a web app: entities, backend functions or server code, workflows, pages and layouts, the project file structure, authentication (platform login vs the custom-auth boilerplate), user invites and the GitHub features. Read first when starting work on a Base44 web app." +--- +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 platform + +A Base44 web app is a Vite + React SPA or a TanStack Start project backed by Base44's entities, functions and auth. This reference is the platform orientation the builder itself works from: concepts, file layout, authentication wiring and user invites. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Web app, Vite SPA](references/web-spa.md) | +| Web app, TanStack Start | [Web app, TanStack Start](references/web-tanstack.md) | +| Native mobile app (Expo, React Native) | [Native mobile app (Expo)](references/mobile.md) | diff --git a/skills/base44-platform-platform/references/mobile.md b/skills/base44-platform-platform/references/mobile.md new file mode 100644 index 0000000..6a87190 --- /dev/null +++ b/skills/base44-platform-platform/references/mobile.md @@ -0,0 +1,58 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 platform: Native mobile app (Expo) + +Here's a list of concepts you can use to develop the app: +<backend> +- entities: entities are defined as json schema objects that define the shape of the data that is stored in the app. +- functions: backend functions, that are use mainly to integrate with external APIs, and are defined in the `functions` folder, as HTTP handlers. +- automations: automations run backend functions automatically. Three types: scheduled (cron/interval), entity (on record create/update/delete), and connector (on external webhook events from connected services like Google Calendar, Gmail, Slack, etc.). + +</backend> +<frontend> +- screens: screens are defined as React Native components in the `app/` directory — each file is automatically a route (expo-router file-based navigation). +- components: components are defined as React Native components, that can be used on screens. +- root layout: `app/_layout.jsx` wraps every screen (navigation stack, providers). +</frontend> + +<file_structure> +This is an Expo + React Native project using expo-router (file-based routing). + +Standard directories: +- app/... (jsx files) — every file is a screen/route (e.g., app/index.jsx is the home screen, app/settings.jsx → /settings, app/items/[id].jsx → /items/:id) +- app/_layout.jsx — root layout (Stack navigator, providers, global.css import) +- src/components/... (jsx files) — reusable components, can have subfolders +- base44/entities/<Name>.jsonc — entity JSON schema files +- base44/functions/<name>/entry.ts — backend functions +- tailwind.config.js (Tailwind/NativeWind theme configuration) +- global.css (Tailwind directives) + +You are free to create additional directories and files as needed (e.g., src/lib/, src/hooks/, src/context/, src/utils/). +IMPORTANT: In code, import shared code with the @/ alias (e.g., import TaskCard from "@/components/TaskCard") — the alias maps to src/. Screen files in app/ are routes, never import them manually. + +Create, edit and delete files directly at these paths; a change is live once the file is written. +</file_structure> + +<authentication> +Authentication backend (token issuance, sessions, email verification) is handled by the base44 platform. +You do NOT need to implement any auth backend logic. + +Login and authentication are handled by the base44 platform, +you don't need to worry about them nor to implement them. +Therefore, IMPORTANT: NEVER try to create a login page. +</authentication> + +<invite_user> +You can use the `base44.users.inviteUser` function from base44 sdk in order to invite users to the app. +It accepts email and role in the app ("user" or "admin"). Make sure you understand the role that the builder is aiming for. + +Since the app is public, both admin users and regular users can invite others. However, only admin users can invite with role="admin", while regular users can only invite with role="user". Pay attention where you locate the call to this function, otherwise it may not work as expected. + +Example: +```js +import { base44 } from '@/api/base44Client'; + +await base44.users.inviteUser("foo@gmail.com", "user") +``` +</invite_user> diff --git a/skills/base44-platform-platform/references/web-spa.md b/skills/base44-platform-platform/references/web-spa.md new file mode 100644 index 0000000..acf719c --- /dev/null +++ b/skills/base44-platform-platform/references/web-spa.md @@ -0,0 +1,70 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 platform: Web app, Vite SPA + +<concepts> +Backend: entities (JSON schemas defining stored data); functions (external-API integration, HTTP handlers in `base44/functions/{functionName}/entry.ts`); workflows (multi-step processes — triggers/conditions/delays/branching — as `.jsonc` in `base44/workflows/`). +Frontend: pages (React components composing the app), components, layout (wraps every page). +</concepts> + +<file_structure> +Standard Vite + React project; create files anywhere. + +- src/pages/... (jsx, subfolders ok) · src/components/... (jsx, subfolders ok) · base44/entities/... (jsonc) · base44/agents/... (jsonc) · base44/functions/{functionName}/entry.ts · base44/workflows/... (jsonc) +- src/App.jsx (router) · index.html · src/index.css (design tokens) + tailwind.config.js +- Extra dirs as needed (src/lib/, src/hooks/, …). Frontend files use src/ paths; entities/agents/functions/workflows use their base44/… paths above; code imports use the @/ alias (never relative src/ paths — they break on moves). + +Create, edit and delete files directly at these paths; a change is live once the file is written. +</file_structure> + +<authentication> +The platform owns the auth backend (tokens, sessions, email verification) — never implement auth backend logic. +Whether this app is enrolled in custom auth is in the app profile (`custom_auth_enrolled`). Not enrolled: the platform provides the login experience — never create a login page. Enrolled: the rest of this section applies. + +Boilerplate auth pages already exist on disk — never create, recreate, make parallel versions of, or preemptively rewrite them. If one seems missing from your context, assume it still exists; read the file to confirm before writing one: +`src/pages/Login.jsx` (<Login />) · `src/pages/Register.jsx` · `src/pages/ForgotPassword.jsx` · `src/pages/ResetPassword.jsx` (reads `?token=`). Fully functional: flows, Google OAuth, OTP, reset, errors, redirects. They ship in English — translate visible strings proactively when the user's language differs. + +Seeding guarantees the page files, NOT their routes — `src/App.jsx` may or may not have them registered yet. As soon as the app gets any auth surface — a login/sign-up entry point, an admin/management/dashboard screen, a `ProtectedRoute`-gated page, or anything that redirects to login — all four routes below must be registered in `src/App.jsx`: keep every one already there, ADD the missing ones. All four go in together even when only one of them was asked for, because each page links to the others and the reset email targets `/reset-password`. Never remove or rewrite an auth route that already exists. Gate authenticated pages by nesting them under `ProtectedRoute` as a layout route. Use these literal paths: +```javascript +import ProtectedRoute from '@/components/ProtectedRoute'; +<Routes> + <Route path="/login" element={<Login />} /> + <Route path="/register" element={<Register />} /> + <Route path="/forgot-password" element={<ForgotPassword />} /> + <Route path="/reset-password" element={<ResetPassword />} /> + <Route element={<ProtectedRoute unauthenticatedElement={<Navigate to="/login" replace />} />}> + <Route path="/" element={<Home />} /> + </Route> + <Route path="*" element={<PageNotFound />} /> +</Routes> +``` +ProtectedRoute lives at `src/components/ProtectedRoute.jsx` (props: fallback, unauthenticatedElement; renders <Outlet /> when authed). Recreate at same path/name if deleted. + +Customize auth pages only on explicit request; edit in place; preserve every flow step (register → OTP stays multi-step in any layout — never shortcut OTP with loginViaEmailPassword). + +SDK (for template edits): +- loginViaEmailPassword(email, password) — sets token; then hard redirect to the boilerplate's post-login destination — it resolves a same-origin `returnTo` query param (falling back to `'/'`); preserve that handling when editing, never hardcode a bare `'/'` +- loginWithProvider(provider, fromUrl) — "google"|"facebook"|"microsoft"|"apple"; pass post-login dest as fromUrl (the resolved `returnTo`, else e.g. createPageUrl("Home")); redirects +- register({email, password}) — does NOT log in; user unverified. Required: register → OTP → verifyOtp → setToken(access_token) → hard redirect. Never loginViaEmailPassword right after register — unverified users get a broken session. +- verifyOtp({email, otpCode}) — returns access_token → setToken → hard redirect +- resendOtp(email) +- resetPasswordRequest(email) — always show generic success (API hides whether email exists) +- resetPassword({resetToken, newPassword}) — then hard-redirect to login route if it exists, else `/` +- isAuthenticated() → Promise<boolean> · logout(redirectUrl?) +Hard redirects (window.location.href), never navigate() — the auth provider must re-initialize. Auth calls throw: keep the templates' try/catch + inline errors + disabled submit while in-flight (exception: resetPasswordRequest always shows generic success). + +Missing template — only after reading the directory confirms it is actually absent (e.g. a deleted/legacy app, often surfaced by a `Cannot resolve '@/pages/Login'` build error — but a build error alone never justifies recreation; check first): RECREATE it at the exact path/name per the SDK rules above — never "fix" by removing the import/route or substituting something simpler. Login: email+password, Google button, forgot link. Register: email+password+confirm, Google, register→OTP→verifyOtp flow, resend. ForgotPassword: email → resetPasswordRequest. ResetPassword: `?token=` + new password+confirm → resetPassword. + +</authentication> + +<github_integration> +Two GitHub features exist: 2-way repo sync, and a GitHub connector (build features on GitHub data). On "GitHub", ask which one before proceeding. +</github_integration> + +<invite_user> +`await base44.users.inviteUser("foo@gmail.com", "user")` invites a user — email + app role "user" or "admin"; confirm which role the builder wants. + +Public app: admins invite both roles; regular users only role="user". Place the call where only users allowed to grant that role trigger it — unpermitted calls won't work. + +</invite_user> diff --git a/skills/base44-platform-platform/references/web-tanstack.md b/skills/base44-platform-platform/references/web-tanstack.md new file mode 100644 index 0000000..9bda2b8 --- /dev/null +++ b/skills/base44-platform-platform/references/web-tanstack.md @@ -0,0 +1,70 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 platform: Web app, TanStack Start + +<concepts> +Backend: entities (JSON schemas defining stored data); server functions and server routes (this app's own server code, see <server_code>). +Frontend: routes (one React component per file in `src/routes/`, server-rendered by default), components, layouts (route files that render `<Outlet />`). +</concepts> + +<file_structure> +TanStack Start project (Vite, built for Cloudflare Workers — never remove or change the plugins in `vite.config.js`); create files anywhere under src/. + +- src/routes/... (jsx — one file per route, see <file_routes>) · src/routes/api/... (js — server routes) · src/lib/server-fns.js (server functions) · src/lib/*.server.js (server-only code) · src/components/... (jsx, subfolders ok) · base44/entities/... (jsonc) · base44/agents/... (jsonc) +- src/routes/__root.jsx (the HTML document + providers) · src/index.css (design tokens) + tailwind.config.js · src/router.jsx · src/start.js · src/routeTree.gen.ts (generated — never edit) +- Extra dirs as needed (src/lib/, src/hooks/, …). Frontend files use src/ paths; entities/agents use their base44/… paths above; code imports use the @/ alias (never relative src/ paths — they break on moves). + +Create, edit and delete files directly at these paths; a change is live once the file is written. +</file_structure> + +<authentication> +The platform owns the auth backend (tokens, sessions, email verification) — never implement auth backend logic. +Whether this app is enrolled in custom auth is in the app profile (`custom_auth_enrolled`). Not enrolled: the platform provides the login experience — never create a login page. Enrolled: the rest of this section applies. + +Boilerplate auth components already exist in `src/components/auth/` — never create, recreate, make parallel versions of, or preemptively rewrite them. If one seems missing from your context, assume it still exists; read the file to confirm before writing one: +`LoginPage` · `RegisterPage` (register → email code → verifyOtp) · `ForgotPasswordPage` · `ResetPasswordPage` (reads `?token=`) · `AuthGate.jsx` (`requireSignedIn`, `AuthGateError`), each in `src/components/auth/<Name>.jsx`, with the shared `AuthLayout`. Fully functional: flows, Google OAuth, OTP, reset, errors, redirects. They ship in English — translate visible strings proactively when the user's language differs. + +The boilerplate guarantees the components, not their routes — the starter has no auth routes, and a file under `src/routes/` is live as soon as it exists. As soon as the app gets any auth surface — a login/sign-up entry point, an admin/management/dashboard screen, a gated page, or anything that sends visitors to login — all four route files below must exist: keep every one already there, add the missing ones. All four go in together even when only one of them was asked for, because each page links to the others and the reset email targets `/reset-password`. Never remove or rewrite an auth route that already exists. Each only wires its component: +```javascript +// src/routes/login.jsx — register.jsx (RegisterPage), forgot-password.jsx (ForgotPasswordPage) and reset-password.jsx (ResetPasswordPage) follow the same shape +import { createFileRoute } from "@tanstack/react-router"; +import { LoginPage } from "@/components/auth/LoginPage"; +export const Route = createFileRoute("/login")({ ssr: false, component: LoginPage }); +``` +Gate authenticated pages with one pathless layout route and put each gated page under `src/routes/_authed/` (`_authed/dashboard.jsx` → `/dashboard`, `_authed/index.jsx` → `/`; the segment adds nothing to the URL): +```javascript +// src/routes/_authed.jsx +import { createFileRoute } from "@tanstack/react-router"; +import { AuthGateError, requireSignedIn } from "@/components/auth/AuthGate"; +export const Route = createFileRoute("/_authed")({ ssr: false, beforeLoad: requireSignedIn, errorComponent: AuthGateError }); +``` +The gate resolves the session before any child loader runs, sends a signed-out visitor to `/login?returnTo=…` and shows the not-registered screen to a Base44 user who isn't a user of this app; a child reads the visitor with `Route.useRouteContext().user`. Never re-implement the gate inside a page, and never put an auth route under `_authed/`. + +Sign-in entry point: a Sign in button calls `useAuth().navigateToLogin()`, which sends the visitor to `/login` and back to the current page; Sign out calls `useAuth().logout()`. + +Customize auth pages only on explicit request; edit the components in place; preserve every flow step (register → OTP stays multi-step in any layout — never shortcut OTP with loginViaEmailPassword) and the `returnTo` handling: every page resolves a same-origin `returnTo` with `safeReturnTo()` from `src/lib/authReturnTo.js` (falling back to `'/'`) — keep it when editing, never hardcode a bare `'/'`. + +SDK (for template edits): +- loginViaEmailPassword(email, password) — sets token; then hard redirect to the resolved `returnTo` +- loginWithProvider(provider, fromUrl) — "google"|"facebook"|"microsoft"|"apple"; pass the resolved `returnTo` as fromUrl; redirects +- register({email, password}) — does not log in; user unverified. Required: register → OTP → verifyOtp → setToken(access_token) → hard redirect. Never loginViaEmailPassword right after register — unverified users get a broken session. +- verifyOtp({email, otpCode}) — returns access_token → setToken · resendOtp(email) +- resetPasswordRequest(email) — always show generic success (API hides whether email exists) · resetPassword({resetToken, newPassword}) — then hard-redirect to `/login` +- isAuthenticated() → Promise<boolean> · logout(redirectUrl?) +Hard redirects (window.location.href), never navigate() — the app must re-initialize with the new session. Auth calls throw: keep the pages' try/catch + inline errors + disabled submit while in-flight (exception: resetPasswordRequest always shows generic success). + +Missing component — only after reading the directory confirms it is actually absent (a build error alone never justifies recreation; check first): recreate it at the exact path/name per the rules above, reusing `AuthLayout` and `parts.jsx` in `src/components/auth/` — never "fix" by deleting a route or substituting something simpler. + +</authentication> + +<github_integration> +Two GitHub features exist: 2-way repo sync, and a GitHub connector (build features on GitHub data). On "GitHub", ask which one before proceeding. +</github_integration> + +<invite_user> +`await base44.users.inviteUser("foo@gmail.com", "user")` invites a user — email + app role "user" or "admin"; confirm which role the builder wants. + +Public app: admins invite both roles; regular users only role="user". Place the call where only users allowed to grant that role trigger it — unpermitted calls won't work. + +</invite_user> diff --git a/skills/base44-platform-workflows/SKILL.md b/skills/base44-platform-workflows/SKILL.md new file mode 100644 index 0000000..1761357 --- /dev/null +++ b/skills/base44-platform-workflows/SKILL.md @@ -0,0 +1,26 @@ +--- +name: base44-platform-workflows +description: "Base44 workflows: trigger-driven automated processes defined as CNCF Serverless Workflow files under base44/workflows/ (scheduled, entity, connector, in-app agent, auth, publish and payment triggers; call, wait and switch steps; jq expressions), with the full authoring guide, plus the legacy automations pipeline for apps that predate workflows. Use for any 'when X happens, do Y' or 'every N hours, do Y'." +--- +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 workflows + +Workflows is the default pipeline: a `.jsonc` envelope under `base44/workflows/` is live once written. An app whose profile says `automation_pipeline: legacy_automations` uses the automations reference instead. + +> **Requires the Builder plan or above.** The workspace's entitlement is reported by the agent-profile endpoint; on a lower plan the platform rejects the deploy. + +## Pick the reference for this app + +Determine the app profile first. Call `GET /api/apps/{app_id}/agent-profile` (over the Base44 MCP `execute_api` +tool, or with your Base44 CLI token); it returns the framework, app type, runtime, +entitlements and the exact reference files to read. Without it, detect from the project files: +`src/routes/__root.*` and `src/routeTree.gen.ts` mean TanStack Start; `app/_layout.*` means a +native mobile (Expo) app; otherwise it is a Vite SPA. + +| Profile | Read | +|---|---| +| Web app, Vite SPA (the default web template) | [Workflows (Vite SPA and mobile: steps call backend functions)](references/backend-functions.md), [Legacy automations (apps that predate workflows)](references/automations-legacy.md) | +| Web app, TanStack Start | [Workflows (TanStack Start: steps call server routes)](references/web-tanstack.md) | +| Native mobile app (Expo, React Native) | [Workflows (Vite SPA and mobile: steps call backend functions)](references/backend-functions.md), [Legacy automations (mobile apps that predate workflows)](references/mobile-automations.md) | diff --git a/skills/base44-platform-workflows/references/automations-legacy.md b/skills/base44-platform-workflows/references/automations-legacy.md new file mode 100644 index 0000000..0a59fe9 --- /dev/null +++ b/skills/base44-platform-workflows/references/automations-legacy.md @@ -0,0 +1,58 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 workflows: Legacy automations (apps that predate workflows) + +<automations_capability> +Automations run backend functions automatically — on a schedule, entity record changes, connector webhook events, or app users starting in-app agent conversations. +Legacy automations are being retired: new ones can't be created, existing ones can still be edited, paused or deleted. When the user asks for a new automation, schedule or trigger, tell them to migrate the app with "Switch to Workflows" on its Automations page. + +<user_timezone> +Times the user mentions ("9am", "midnight") are local to them — ask for their timezone when it is unknown: +- one_time_date, ends_on_date, start_time take local time (auto-converted to UTC). +- cron_expression runs in UTC — compute the UTC equivalent yourself. +</user_timezone> + +<managing_automations> +This app runs the legacy automations pipeline (app profile `automation_pipeline: legacy_automations`). Automations are not files: update, toggle and delete the existing ones in the dashboard (Automations); new ones can't be created. A new schedule or trigger means migrating the app with "Switch to Workflows" on its Automations page, after which this skill's workflows reference applies. The parameters below are the ones the dashboard accepts, named as the builder passes them. +</managing_automations> + +<scheduled_automations> +automation_type="scheduled". Minimum interval: 5 minutes. +schedule_type="simple": repeat_interval=5, repeat_unit="minutes" | repeat_interval=1, repeat_unit="hours" | daily: repeat_interval=1, repeat_unit="days", start_time="09:00" | weekly: repeat_unit="weeks", repeat_on_days=[1, 5], start_time="10:00" (0=Sunday, 6=Saturday) | monthly: repeat_unit="months", repeat_on_day_of_month=15, start_time="00:00". +schedule_type="cron": standard "minute hour day-of-month month day-of-week" — "*/5 * * * *" = every 5 minutes, "0 9 * * 1-5" = 9am weekdays. +schedule_mode="one-time": one_time_date="2024-01-15T10:00:00". +Ends: ends_type="never" (default) | "on" + ends_on_date="2024-12-31T23:59:59" | "after" + ends_after_count=10. +</scheduled_automations> + +<entity_automations> +automation_type="entity" — fires on entity record changes. Required: entity_name, event_types (array of "create", "update", "delete"). +Function payload: event { type, entity_name, entity_id }; data: current entity data; old_data: previous data ("update" only); changed_fields: top-level fields that changed (e.g. ["status", "amount"]); payload_too_large: true if entity data exceeded 200KB — data/old_data then null, fetch with `await base44.asServiceRole.entities[event.entity_name].get(event.entity_id)`. + +Optional trigger_conditions pre-filter entity events. Conditions evaluate against a wrapped object (entity automations only, not connector): {"data": {...current...}, "old_data": {...previous, update only...}, "changed_fields": [...]}. Field paths take the wrapping prefix: "data.<field>", "old_data.<field>", or "changed_fields" with operator "contains" to test whether a field changed. + +Example (only when status changes to "completed"): +trigger_conditions={"logic": "and", "conditions": [{"field": "changed_fields", "operator": "contains", "value": "status"}, {"field": "data.status", "operator": "equals", "value": "completed"}]} +</entity_automations> + +<in_app_agent_automations> +automation_type="in_app_agent" — fires when an app user starts a new in-app agent conversation. +Fields: function_name, agent_name (exact configured name; "" clears = all agents), event_types (currently only "conversation_started"). +Function payload: automation { id, type, name }; event { type, agent_name, conversation_id, message_id, channel }; message { text, has_files }; app_user { id }; payload_too_large (message text omitted as too large). +</in_app_agent_automations> + +<connector_automations> +automation_type="connector" — fires on a connected service's webhook events (new calendar event, Drive file change, …). +Fields: integration_type (e.g. "googlecalendar", "googledrive"), events (array of event names), function_name. Provider payloads/auth differ — read the provider's own webhook documentation for payload and auth before editing the handler (`list_connectors` gives the integration type, scopes and connection state, not a handler guide). + +Optional trigger_conditions pre-filter webhook events: {"logic": "and", "conditions": [{"field": "<dot.path>", "operator": "<op>", "value": "<target>"}]} +- field: dot-path into the provider's raw webhook payload — no "data." prefix (unlike entity conditions); Slack: "event.channel", "event.text", "event.user". Take paths from the provider's public webhook payload docs. +- operator: equals | not_equals | gt | gte | lt | lte | contains | not_contains | starts_with | ends_with | in_list | not_in_list | exists | not_exists | is_empty | is_not_empty +- value: concrete value. If the user gives a human-readable name but the payload carries an internal ID, resolve it first: temporary backend function using the authorized connector, invoke it, use the returned ID — never guess IDs. E.g. #general Slack: conversations.list → {"field": "event.channel", "operator": "equals", "value": "C0123ABC"}. +</connector_automations> + +<usage_guidelines> +- Existing automations and their IDs: the dashboard (Automations). +- Pause/resume from the dashboard. +</usage_guidelines> +</automations_capability> diff --git a/skills/base44-platform-workflows/references/backend-functions.md b/skills/base44-platform-workflows/references/backend-functions.md new file mode 100644 index 0000000..c0d4411 --- /dev/null +++ b/skills/base44-platform-workflows/references/backend-functions.md @@ -0,0 +1,632 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 workflows: Workflows (Vite SPA and mobile: steps call backend functions) + +<workflows_capability> +Workflows: trigger-driven automated processes — one step (e.g. a scheduled function call) or complex (multi-step, `switch` branching, durable waits across hours/days, data threading, chained backend functions). For any "when X happens, do Y" / "every N hours/days, do Y", however many actions Y involves. + +<files_and_runs> +Workflow files live at `base44/workflows/<Name>.jsonc`; writing the file creates or updates the workflow, deleting it removes the workflow. Status and runs: `base44 workflows list --app-id <APP_ID>` and `base44 workflows runs --app-id <APP_ID> [--status failed]`, or the Workflows dashboard. +</files_and_runs> + +<workflow_building_blocks> +What workflows can be built from; the authoring guide that follows documents each in full. +## Workflow Building Blocks + +### Triggers (what starts a workflow) +- scheduled — Fires on a schedule — a cron cadence, a fixed interval, or a one-time future instant. +- entity — Fires when an entity record is created, updated, or deleted. +- connector — Fires on a real-time webhook event from an authorized connector (Slack, Google Calendar, Drive, …). +- in_app_agent — Fires when a conversation starts with an in-app agent. +- app_user_auth — Fires when an app user signs up or logs in. +- app_publish — Fires when the app is published from the builder. +- app_payment — Fires when a payment succeeds or is refunded, or a subscription is canceled, in the app's payment provider. +- webhook — Fires on an inbound POST to a reusable workspace webhook URL, from any source. Use it for a third-party event no connector trigger covers - don't connect that provider or poll it to get the event. + +### Activities (what a `call` step can do) +- app_security_scan — Security scan (same as the Security tab): `start` kicks it off, `results` reads the latest findings. +- compute_seconds_until — Computes the seconds from now until a target datetime — feed the result into a dynamic wait. +- invoke_backend_function — Runs a deployed backend function — your code for API calls, data transforms, and entity CRUD. + +### Task Types (how steps are structured) +- call — invoke a registered activity with `with` args, then transition via `then`. +- wait — pause for an ISO-8601 duration (durable, survives restarts), then continue. +- switch — branch on ordered jq conditions; the last case is the catch-all `${ true }`. +</workflow_building_blocks> + +<creating_workflows> +1. Read the authoring guide below. 2. Needed backend functions first. 3. Connector triggers: authorize first. 4. Write `base44/workflows/<Name>.jsonc` (the envelope). 5. Auto-validation on save — fix anything reported as WORKFLOW ERROR. +Each write creates a new version; running workflows keep their starting version. Graph + run history: dashboard. +</creating_workflows> + +<debugging_workflows> +`base44 workflows runs --app-id <APP_ID> --status failed` lists failed runs with the failing task and its error; `base44 logs --function <name>` reads that function's own logs; the Workflows dashboard shows each run's steps. +</debugging_workflows> +</workflows_capability> + +# Workflow Authoring Guide + +> **Filtered guide** — documents ONLY triggers: scheduled, entity, connector, in_app_agent, app_user_auth, app_publish, app_payment, webhook; activities: app_security_scan, compute_seconds_until, invoke_backend_function. Any other trigger or activity is NOT covered here: do not author with it from memory — call this tool again with the names you need. + +## File Convention + +Workflows are `.jsonc` files under `base44/workflows/`. +- Path: `base44/workflows/<WorkflowName>.jsonc` +- Existing workflows: `base44 workflows list --app-id <APP_ID>` (runs: `base44 workflows runs --app-id <APP_ID>`), or the Workflows dashboard +- Create or update a workflow by writing its file +- Each write creates a new version. Already-running workflows keep the version they started with. +- After a successful write, the workflow is immediately active — scheduled triggers start firing, entity/connector triggers start listening. +- The workflow appears in the code view sidebar under Workflows and in the Workflows dashboard. + +## Managing Workflow Status + +Change lifecycle status (activate / deactivate / archive / unarchive) in the Workflows dashboard or through the platform API (the `workflows` area of the API catalog) — NOT file edits. + +- Stop/pause/cancel all: refresh the list, deactivate every active workflow, and report failures before claiming success. +- Destructive broad/bulk workflows: encode user protections in the definition (not conversation memory), preview scope/count, and get approval before activation or active updates. + +## Envelope Format + +```json +{ + "name": "Human-Readable Workflow Name", + "description": "What this workflow does", + "trigger": { ... }, + "definition": { ... } +} +``` + +## CNCF Serverless Workflow v1.0 Definition + +```json +{ + "document": { "dsl": "1.0.0", "name": "snake_case_id", "version": "1.0", "namespace": "base44" }, + "do": [ + { "task_name": { "call": "activity_name", "with": {}, "then": "next_task" } } + ] +} +``` + +**Task naming**: must be snake_case (letters, digits, underscores). Task results are accessed in jq as `${ .task_name.field }` — hyphens break jq field access. Never start a task name with `_` — that namespace is reserved for the system and the save will be rejected. + +**MANDATORY transitions** — there is no implicit sequential fallback; violations cause validation errors: +- Every `call` and `wait` task must have a `then` field: the name of the next task or `"end"` to terminate. +- `switch` tasks branch ONLY through their cases: every case must have its own `then`, the **last case must be the catch-all `{"when": "${ true }", "then": ...}`** ("otherwise") so every run takes an explicit, visible branch, and the switch task itself must NOT have a task-level `then` — with an exhaustive case list it would be dead code. +- A switch needs at least one real condition case besides the catch-all. A switch whose only case is `${ true }` is a no-op and is rejected. + +**Execution model: strictly sequential.** Tasks run one at a time in `then` order — there is NO parallel execution, fork/join, or "branches running at the same time". Never fabricate "fan-out" / "converge" marker nodes (e.g. a pass-through switch) to make the graph look parallel: the graph would misrepresent what actually runs. If the user asks for parallel branches, explain that steps execute in sequence and model the flow as ordered steps. + +## Supported Task Types + +### call — invoke a registered activity +```json +{ "fetch_data": { "call": "invoke_backend_function", "with": { "function_name": "getData", "args": {} }, "then": "next_task" } } +``` + +### switch — conditional branching +```json +{ "check": { "switch": [ + { "when": "${ .fetch_data.status == \"active\" }", "then": "handle_active" }, + { "when": "${ true }", "then": "end" } +] } } +``` +Cases are evaluated in order — the first truthy `when` wins (transition rules above). + +### wait — durable time delay (survives restarts) +```json +{ "pause": { "wait": "PT5M", "then": "next_task" } } +``` +ISO 8601: PT5M = 5 min, PT1H = 1 hour, P1D = 1 day, P3DT12H = 3.5 days + +## `x-base44` (display-only node labels - set on every task) + +Set `x-base44` on every task for graph labels only — it never changes runtime behavior, task output, transitions, or activity inputs. Allowed sub-fields (anything else is rejected): `title` (node title — **always in the user's language**; the task key stays `snake_case` ASCII) and optional `description` (very short subtitle). + +```json +{ "fetch_data": { "call": "invoke_backend_function", "with": { "function_name": "getData" }, "then": "next_task", "x-base44": { "title": "Fetch order", "description": "Latest record" } } } +``` + +When editing, preserve `x-base44` where the task's purpose is unchanged; update `title`/`description` when the meaning changes — no stale labels. + +## Available Activities + +### app_security_scan +The app's security scan — the same scan as the builder's Security tab (RLS, secrets, backend functions, dependencies, static code). `action: start` starts a scan and returns at once, without findings. `action: results` reads the latest completed scan. +- When to use: Whenever a workflow needs the app's security posture or findings, on any trigger — to refresh them (`start`) or to read and act on them (`results`). A scan runs in the background for up to ~10 minutes, so `start` never returns findings: `wait` at least PT10M, then `results` passing `after: ${ .<start step>.requested_at }` so an older scan is reported as `pending`, not as ready. If `results` says `running`, wait PT5M and read once more. `results` alone is enough when the latest scan will do. Branch on `state` — only `ready` carries `scan`. Zero findings means clean only when `scan.status` is `up_to_date` and `scan.rls_scan_failed` is false. `scan.findings` is capped: severity-first, 50 by default — pass `max_findings` (up to 100) to raise it, check `findings_truncated`, and take the count from `total_findings`, never from the list length. If nothing changed since the last scan, `start` itself returns `ready` with that scan. Run now behaves like any other run: `start` really starts a scan. +- `action`: `start` to start a scan, or `results` to read the latest completed scan. Required. +- `after`: results only, optional. ISO-8601 datetime; a scan completed before it is reported as `pending` rather than `ready`. Pass the `requested_at` returned by the `start` step. +- `max_findings`: results only, optional. How many findings to include in `scan.findings`, severity-first (default 50, max 100); `scan.findings_truncated` says whether any were left out. +- Returns: {"action": "start" | "results", "state": str, "scan": {...} | null, "requested_at": str (start only; the `after` for a follow-up results — when state is "ready" it is the returned scan's own scanned_at, so results accepts it)}. start states: "started" (scan spawned), "running" (a scan is already in flight), "ready" (nothing changed since the last scan; `scan` is current). results states: "ready" (`scan` set), "running", "pending" (latest scan predates `after`), "failed" (last scan failed), "not_scanned". `scan` is set only when state is "ready": {"scanned_at": str, "status": "up_to_date" | "out_of_date" (as the Security tab shows it), "rls_scan_failed": bool (RLS section did not complete — empty RLS findings are not a clean result), "risk_level": str, "total_findings": int, "counts_by_severity": {severity: int}, "counts_by_category": {category: {severity: int}}, "finding_categories": [str], "findings": [{"category": "rls" | "secrets" | "backend_functions" | "dependencies" | "code" | "core_integration", "severity": str, "title": str, "description": str, "file_path"?: str, "line"?: int, "recommendation"?: str, "package_name"?: str, "current_version"?: str, "fixed_version"?: str, "vulnerability_id"?: str}], "findings_truncated": bool}. `start` never returns findings (its `scan.findings` is empty). + +### compute_seconds_until +Compute seconds from now until a target datetime. +- `target_iso`: ISO 8601 datetime string +- Returns: {"seconds": float, "target_iso": str, "now_iso": str} + +### invoke_backend_function +Call a deployed Deno backend function. +- When to use: Logic you write as code: API calls, data fetching/transforms, entity CRUD, sending email/HTTP. You control exactly what it does — no agent reasoning turn. +- `function_name`: Name of the backend function to call +- `args`: Dict of arguments passed as the function request body (supports jq expressions) +- Returns: Parsed JSON response from the function + +## Choosing Between Activities + +See each activity's "When to use" line above for what it's best at. + +## jq Expression Language + +Use `${ .field }` syntax (CNCF SWF v1.0). +- Trigger data: `${ .trigger.data.fieldname }` +- Task output: `${ .task_name.field }` +- Conditions: `${ .trigger.data.status == "active" }` +- String interpolation: `"Hello ${ .trigger.data.name }"` + +**Write jq, not JavaScript/Python.** Common mistakes to avoid: +- Logic operators are `and` / `or` / `not`, never `&&` / `||` / `!`: `${ .trigger.data.a == 1 and .trigger.data.b != 2 }`. +- Membership is `${ .trigger.data.event.channel | IN("C1","C2") }`, never infix `x in [...]`. +- String tests (`test` / `contains` / `startswith` / `ascii_downcase`) throw on a null field — default first: `${ (.trigger.data.subject // "") | test("receipt"; "i") }`. +- Iteration throws on null — default with `// []`: `${ any((.trigger.data.items // [])[]; .qty > 0) }`. +- In a pipe, `.` is the piped value, not the root — bind root refs first: `${ .trigger.data.event.channel as $c | ["C1","C2"] | index($c) != null }`. + +### Keep jq minimal + +jq only reads the payload (trigger data + prior task outputs): field access, equality, simple boolean/arithmetic checks. Anything more — transformation, computation, lookups, multi-step branching — belongs in a backend function (`invoke_backend_function`), not in elaborate jq. +- Never use `env` / `$ENV`, `input`, `def`, `range`, `recurse`, `reduce`, `foreach`, `walk`, `..` — the save is rejected. jq cannot reach app secrets; only a backend function can read and use an API key or signing secret. +- Good: `${ .trigger.data.status == "approved" }`. Bad: `${ env.API_KEY }`. + +## Trigger Types + +The trigger object wraps the type-specific fields (shown below) in `config`, plus an optional jq `condition` that must be truthy for the workflow to run: +```json +{ "config": { "trigger_type": "<type>", <type-specific fields — see your trigger's section> }, "condition": "${ <jq predicate over .trigger, optional> }" } +``` + +### scheduled +```json +{ "trigger_type": "scheduled", "cron_expression": "0 9 * * 1-5", "timezone": "America/New_York" } +``` + +**Webhook vs polling**: when the user wants to react to external events (calendar changes, new messages, CRM updates), prefer a connector trigger over scheduled polling — connector triggers deliver events in real time. Use scheduled polling only when no connector supports webhooks for the service (check the connector's details with `list_connectors`). + +**Timezone**: Ask the user for their timezone when it is not known. When the user mentions times (e.g., "9am", "2pm", "midnight"), they mean times in THEIR timezone. Set the `timezone` field to the user's timezone — the cron expression is then evaluated in that timezone (no manual UTC conversion needed). + +Cron format: `minute hour day-of-month month day-of-week` + +**Choose cron vs interval FIRST.** Cron fires at clock-aligned times, so a "every N <unit>" request is only expressible in cron when N evenly divides its field's wrap period — minutes where N divides 60 (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30) or hours where N divides 24 (1, 2, 3, 4, 6, 8, 12). For any other fixed gap — "every 90 minutes", "every 45 minutes", "every 7 hours", "every 2 days" — cron CANNOT express it: use **interval mode** (see below). Never approximate with `*/90 * * * *` (most cron parsers reduce the out-of-range minute step to :00, collapsing it to hourly — some reject it outright) or `0 9 */2 * *` (the day-of-month step restarts every month, so it is not a true 2-day gap). + +Translating user requests to cron (clock-aligned cadences): +- "every 5 minutes" → `*/5 * * * *` (minimum interval) +- "every hour" / "hourly" → `0 * * * *` +- "every day at 9am" / "daily at 9" → `0 9 * * *` +- "every weekday at 9am" / "Monday to Friday" → `0 9 * * 1-5` +- "every Monday at 10am" → `0 10 * * 1` +- "every Monday and Friday at 10am" → `0 10 * * 1,5` +- "every month on the 15th" → `0 0 15 * *` +- "every month on the 1st at 9am" → `0 9 1 * *` +- "twice a day at 9am and 5pm" → `0 9,17 * * *` +- "every 30 minutes during business hours" → `*/30 9-17 * * 1-5` +- "every Sunday at midnight" → `0 0 * * 0` + +**Run once** (instead of recurring): set `schedule_mode` to `"one_time"` with a future UTC instant — no cron_expression needed: +```json +{ "trigger_type": "scheduled", "schedule_mode": "one_time", "one_time_date": "2026-07-01T16:00:00Z" } +``` +`one_time_date` is a future UTC instant. Never guess "now" — take the current local time from the user, resolve the user's request against it (relative "in N minutes" or a clock time like "9:12am"), then convert to UTC. + +**Recurring at a fixed interval** — use this for any "every N minutes/hours/days" the cron rule above says cron can't express (e.g. "every 90 minutes", "every 45 minutes", "every 7 hours", "every 2 days"). Set `schedule_mode` to `"interval"` and DO NOT set `cron_expression`: +```json +{ "trigger_type": "scheduled", "schedule_mode": "interval", "interval_value": 2, "interval_unit": "days", "interval_anchor": "2026-07-01T09:00:00Z" } +``` +Units: minutes | hours | days only. Minimum interval is 5 minutes. `interval_anchor` (UTC) sets the phase — when the user gives a time-of-day or start ("every 2 days at 9am", "every 90 minutes starting 14:00") set it to that UTC instant; omit it to align to the epoch. For calendar cadences (specific weekdays, day-of-month, weekly, monthly) use cron. + +**End conditions** (recurring schedules only, default is run forever): +- Stop on a date: add `"ends_type": "on_date", "ends_on_date": "2026-12-31T23:59:00Z"` — the last instant the schedule may fire (UTC). +- Stop after N runs: add `"ends_type": "after_count", "ends_after_count": 10`. + +When a schedule finishes (one-time fired, end date passed, or run count reached) the workflow is automatically archived. + +### entity +```json +{ "trigger_type": "entity", "entity_name": "orders", "events": ["create", "update"] } +``` + +Trigger payload structure: +- `${ .trigger.entity_name }` — entity name (e.g. "orders") +- `${ .trigger.entity_id }` — the entity record ID +- `${ .trigger.event_type }` — "create", "update", or "delete" +- `${ .trigger.data.* }` — current entity data +- `${ .trigger.old_data.* }` — previous data (update events only) +- `${ .trigger.changed_fields }` — array of top-level field names that changed +- `${ .trigger.payload_too_large }` — true when the record was too large to attach (see note below) + +Condition examples: +- Only when status changes to "completed": + `"condition": "${ .trigger.data.status == \"completed\" and .trigger.old_data.status != \"completed\" }"` +- Only when a specific field changed: + `"condition": "${ .trigger.changed_fields | index(\"status\") != null }"` +- Only for a specific entity value: + `"condition": "${ .trigger.data.priority == \"high\" }"` + +Note: when the entity record is very large (over ~256KB), `data` and `old_data` arrive as null and `payload_too_large` is true (`changed_fields` stays populated). Trigger conditions still evaluate against the full data, but the backend function must re-fetch the record by id using the Base44 SDK: `await base44.asServiceRole.entities["entity_name"].get(entity_id)`. + +Use `read_file("base44/entities/<EntityName>.jsonc")` to check entity field names before writing conditions. + +### connector +```json +{"trigger_type": "connector", "integration_type": "slack", "events": ["message"]} +``` + +Optional: `resource_id` — for Google Calendar (calendar ID, e.g. "primary" or "user@gmail.com") or Google Drive (file ID). Controls which webhook registration is used (scopes the provider subscription, not per-workflow dispatch filtering). +```json +{"trigger_type": "connector", "integration_type": "googlecalendar", "events": ["events"], "resource_id": "primary"} +``` + +Prerequisites: +- The connector must be authorized (use the Base44 MCP connector tools `list_connectors` and `initiate_connector_connection`, or `base44 connectors`). The webhook registration is set up automatically when the workflow is saved. +- Each provider has a different payload structure and auth model. Read the provider's own webhook documentation for its payload and auth model (`list_connectors` gives the integration type, scopes and connection state, not a handler guide) BEFORE writing the backend function. + +Trigger payload: +- `${ .trigger.integration_type }` — e.g. "slack", "googlecalendar" +- `${ .trigger.event_type }` — the webhook event type +- `${ .trigger.data.* }` — the raw webhook payload from the provider +- `${ .trigger.payload_too_large }` — true when the webhook payload was too large to attach (over ~256KB); `data` arrives as null (conditions still evaluate against the full payload) and the backend function must re-fetch the resource from the provider + +Condition field paths use the provider's webhook payload structure: +- Slack: `${ .trigger.data.event.channel == \"C0123ABC\" }`, `${ .trigger.data.event.user == \"U0123ABC\" }` +- Google Calendar: `${ .trigger.data.resource_id == \"calendar_id\" }` + +**Important**: When the user provides a human-readable name (e.g. "#general"), the webhook payload uses internal IDs. Resolve the name to an ID first: create a temporary backend function that uses the authorized connector to look up the ID, invoke it once (`base44.functions.invoke`), then use the returned ID in the condition. Never guess IDs. + +High-volume connectors (e.g. Slack) require conditions — validation will reject triggers without them. + +If activation fails with `MISSING_WEBHOOK_SCOPES`, re-connect that connector (`initiate_connector_connection`, or `base44 connectors initiate`) with the union of its granted and missing scopes (re-authorization replaces the grant, so omitting the granted ones drops them), then save the workflow again. + +Available connectors: + - gmail: mailbox + - googlecalendar: events + - googledrive: changes, file, file.update, file.trash, file.untrash, file.delete + - microsoft_teams: created, updated, deleted + - one_drive: updated + - outlook: created, updated, deleted + - share_point: updated + - slack: message, message.im, message.groups, message.channels, message.mpim, reaction_added, reaction_removed, member_joined_channel, member_left_channel, file_shared + - slackbot: app_mention, message.im, message.mpim, message.channels, message.groups + - wix: wix.forms.v4.submission_created, wix.contacts.v4.contact_created, wix.contacts.v4.contact_updated, wix.contacts.v4.contact_deleted, wix.ecom.v1.order_created, wix.ecom.v1.order_updated, wix.ecom.v1.order_canceled, wix.ecom.v1.checkout_created, wix.data.v2.data_item_created, wix.data.v2.data_item_updated, wix.data.v2.data_item_deleted + +### in_app_agent +```json +{ "trigger_type": "in_app_agent", "agent_name": "support_agent", "events": ["conversation_started"] } +``` +Omit `agent_name` to trigger for all in-app agents. `events` is required — currently only `"conversation_started"` is supported. + +Trigger payload structure: +- `${ .trigger.agent_name }` — the agent name (top-level shorthand) +- `${ .trigger.data.event.type }` — always "conversation_started" +- `${ .trigger.data.event.agent_name }` — agent that received the message +- `${ .trigger.data.event.conversation_id }` — conversation ID +- `${ .trigger.data.event.message_id }` — first message ID +- `${ .trigger.data.event.channel }` — channel (e.g. "web", "mobile") +- `${ .trigger.data.message.text }` — first message text +- `${ .trigger.data.message.has_files }` — whether files were attached +- `${ .trigger.data.app_user.id }` — app user ID +- `${ .trigger.payload_too_large }` — true when the first message was too large to attach (over ~256KB); `data.message` arrives as null but the event IDs remain, so fetch the conversation by `data.event.conversation_id` + +### app_user_auth +```json +{ "trigger_type": "app_user_auth", "events": ["signup", "login"] } +``` + +app_user_auth fields live on `.trigger` directly — do NOT use `.trigger.data.*` for this trigger. + +Trigger payload fields (use `${ .trigger.<field> }` in conditions and `do` args): +- `${ .trigger.event_type }` — "signup" or "login" +- `${ .trigger.auth_method }` — "password", "google", "microsoft", "facebook", "apple", "app_sso", or "workspace_sso" +- `${ .trigger.occurred_at }` — ISO timestamp for the successful auth transaction +- `${ .trigger.user_id }` — app-user ID string (preferred for backend-function args) +- `${ .trigger.email }` — app-user email +- `${ .trigger.full_name }` — app-user display name +- `${ .trigger.role }` — app-user role +- `${ .trigger.app_user.id }` — same ID as user_id +- `${ .trigger.app_user.collaborator_role }` — collaborator role, when present +- `${ .trigger.app_user.is_verified }` — whether the app user is verified + +Canonical `invoke_backend_function` args (same for signup and login): +```json +"args": { + "user_id": "${ .trigger.user_id }", + "email": "${ .trigger.email }", + "auth_method": "${ .trigger.auth_method }" +} +``` +Do NOT pass `${ .trigger.app_user }` as user_id — it is an object, not a string. + +When each event fires (depends on whether the app is public or private): + +**Public apps:** +- **signup** — the end user's first successful sign-in (email/password or social login). +- **login** — every later successful sign-in. + +**Private or workspace apps:** +- **signup** — when an admin approves a new user's access request. Does not fire on first login. +- **login** — every successful sign-in after the user exists, including the first sign-in after approval. + +Condition examples: +- Only password auth: + `"condition": "${ .trigger.auth_method == \"password\" }"` +- Only Google signups: + `"condition": "${ .trigger.event_type == \"signup\" and .trigger.auth_method == \"google\" }"` +- Only workspace SSO logins: + `"condition": "${ .trigger.auth_method == \"workspace_sso\" }"` +- Only admin app users: + `"condition": "${ .trigger.role == \"admin\" }"` + +### app_publish +```json +{ "trigger_type": "app_publish", "events": ["published"] } +``` + +app_publish fields live on `.trigger` directly — do NOT use `.trigger.data.*` for this trigger. + +Trigger payload fields (use `${ .trigger.<field> }` in conditions and `do` args): +- `${ .trigger.event_type }` — always "published" +- `${ .trigger.occurred_at }` — ISO timestamp of the publish +- `${ .trigger.is_first_publish }` — true only on the app's first-ever publish +- `${ .trigger.visibility }` — published visibility ("private_with_login", "workspace_with_login", "public_with_login", or "public_without_login") +- `${ .trigger.published_by }` — platform user ID of the publisher, null for API-key publishes + +Fires on every successful publish, including restoring a previous version. Does not fire on unpublish. + +Condition examples: +- Only the first publish (launch): + `"condition": "${ .trigger.is_first_publish }"` + +### app_payment +```json +{ "trigger_type": "app_payment", "events": ["payment_succeeded"] } +``` + +Requires a connected payment provider (Stripe, PayPal, Wix Payments, or Payments by Wix): activating a workflow with this trigger fails when none is connected. Connect a provider first, then create the workflow. The payload is normalized across providers; workflows never receive provider-shaped raw data. + +Valid events: `payment_succeeded` (a successful payment: a completed checkout and the first payment of a subscription), `payment_refunded` (an approved refund, including one made from the Payments dashboard or the provider's own site), `subscription_canceled` (a subscription stopped billing: canceled or ended on Stripe and PayPal, and paused/suspended on PayPal only, e.g. by the buyer in PayPal or after failed payments; a Stripe pause fires nothing; a PayPal subscription resumed outside the app fires nothing and `listPurchases` can't show it, so tell the user to restore that access by hand). Failed payment attempts do NOT fire this trigger. + +Use `payment_refunded` and `subscription_canceled` to revoke what a purchase granted: match the event to the purchase the app saved by whichever of `subscription_id`, `checkout_id` and `payment_id` it carries, and make the revoke idempotent. + +Recurring subscription renewal charges are wired to fire `payment_succeeded` too, but delivery is not enabled yet for every Stripe account. Do not build a workflow whose only purpose is reacting to renewals, and tell the user renewals may not fire yet if they ask for one. The same holds for `subscription_canceled` on Stripe; on PayPal both fire. + +When renewals do arrive, the payload does not distinguish them from a first subscription payment — both are `payment_succeeded` with `mode: "subscription"`. So do not use this trigger for one-time-only actions on a subscription (welcome emails, provisioning, granting access) without your own guard, e.g. an entity record keyed by `provider_customer_id` that the workflow checks and writes. + +app_payment fields live on `.trigger` directly — do NOT use `.trigger.data.*` for this trigger. + +Trigger payload fields (use `${ .trigger.<field> }` in conditions and `do` args): +- `${ .trigger.event_type }`: "payment_succeeded", "payment_refunded" or "subscription_canceled" +- `${ .trigger.provider }`: "stripe", "paypal", "wix_payments", or "payments_by_wix" +- `${ .trigger.amount }`: integer amount in cents for most currencies (e.g. 2500 = $25.00), matching the platform's payment analytics representation; 0 on `subscription_canceled` +- `${ .trigger.currency }` — lowercase ISO currency code (e.g. "usd") +- `${ .trigger.is_live }` — false for test/sandbox payments; test payments DO fire this trigger +- `${ .trigger.mode }`: "payment" (one-off checkout) or "subscription" (a subscription charge, first payment OR renewal, indistinguishable); null on refunds. Only Stripe and PayPal distinguish these; Wix payments always report "payment" +- `${ .trigger.occurred_at }` — ISO timestamp of the provider event +- `${ .trigger.provider_customer_id }` — provider-side customer/contact ID, or null +- `${ .trigger.provider_transaction_id }`: provider-side transaction/session/refund ID, for logs; look purchases up by the ids below instead +- `${ .trigger.provider_event_id }` — provider webhook event ID (webhook redeliveries are already deduped on it — don't build your own guard) +- `${ .trigger.checkout_id }`, `${ .trigger.subscription_id }`, `${ .trigger.payment_id }`: the same ids `base44.payments` returns on a purchase; each is null when the provider does not name it on that event (Wix events carry none; a Stripe renewal carries only `subscription_id`; a refund carries `payment_id`, plus `subscription_id` when it refunds a subscription charge) +- `${ .trigger.subscription_status }`: on `subscription_canceled`: "canceled" (canceled or ended) or "paused" (can resume); null otherwise + +The payload carries no payer email or other PII. When needed, look the purchase up in a backend function, as service role, with `payments.listPurchases({ subscription_id })` when the event names one, else `({ checkout_id })`, which returns `buyer_email`. Never call `listPurchases` with neither id: unscoped it lists every buyer's purchases. Events that carry neither (Wix, or a Stripe payment made outside a checkout) are re-fetched by `provider_transaction_id` from the provider's API instead. Zero-amount payments and refunds (free checkouts, 100% discounts) do not fire. + +Condition examples: +- Only real (live-mode) payments: + `"condition": "${ .trigger.is_live }"` +- Only large payments (over $100): + `"condition": "${ .trigger.amount > 10000 }"` + +### webhook + +A workspace webhook must already exist — this trigger listens on one, it does +not create one. Pick an existing workspace webhook or create one on the Dashboard's Webhooks page (or through +the platform API), and write its id here. Reuse an existing +one where you can: many providers allow only a single webhook URL per account, +so a second listener for the same provider usually cannot be wired up at all. + +```json +{ "trigger_type": "webhook", "webhook_id": "<id from the tool>", + "webhook_name": "hubspot-events", "events": ["contact.creation"] } +``` + +`webhook_id` is the reference — never a URL and never a token. You cannot guess +or invent it: it only exists once a tool hands it to you, and an id that is not +in this workspace blocks activation with `WEBHOOK_NOT_FOUND`. + +`webhook_name` is the listener's display name at authoring time. It is carried for +readability only and is never resolved against — the user may rename the +listener, which changes the label and nothing else. + +One webhook URL commonly carries several event types. `events` filters which +ones run this workflow; an empty list subscribes to all of them. Reusing a +webhook across workflows is normal and preferred — many providers allow only +one webhook URL per account. Only set `events` when the webhook classifies +event types; otherwise activation fails with `WEBHOOK_HAS_NO_EVENT_TYPES`. + +Trigger payload (use `${ .trigger.* }` in conditions and `do` args): +- `${ .trigger.data.<field> }` — the parsed request body, e.g. `${ .trigger.data.properties.email }` +- `${ .trigger.event_type }` — the event type this request was classified as +- `${ .trigger.webhook_name }` — the webhook's display name (may have changed since authoring) +- `${ .trigger.webhook_id }` — the webhook's id, stable across renames +- `${ .trigger.headers.<name> }` — request headers, lowercased, credentials removed +- `${ .trigger.query.<name> }` — query-string params, credentials removed + +`${ .trigger.data }` arrives as `null` when the body was too large to forward. +Unlike an entity trigger there is nothing to re-fetch — the body is never +persisted — so a workflow that must tolerate big payloads should branch on the +absence rather than assume the field is there. + +Condition example — only enterprise-tier contacts: +`"condition": "${ .trigger.data.properties.tier == \"enterprise\" }"` + +## Designing Steps: When to Split vs Combine + +**Split** into separate tasks when operations are independent (a failure shouldn't re-run the others), a delay sits between them, or you want per-step visibility and retry granularity in the run log. **Combine** into one step when operations share setup/auth/transaction, must be atomic, or the second needs the first's full in-memory result. + +**Conditions**: use a `switch` when the outcome picks the path (skip steps, branch, end early — visible in the graph); keep validation/error handling inside the step. Rule of thumb: a flowchart diamond goes in the workflow; an implementation if-statement stays in the step. + +## Important Notes + +- Create the backend function BEFORE the workflow that calls it. +- A step calls the backend function as the workflow's creator, an app admin, but its URL is public. One only workflows call must start with `const user = await base44.auth.me()`, return 401 when there is no user and 403 unless `user.role === 'admin'`, then use `base44.asServiceRole`; never a shared secret. One the app's users also call keeps its usual checks. +- Never put a secret value (API key, token, password) in a workflow — not in step args, conditions or anywhere else. The backend function reads the secret from the app's secrets itself. +- Infinite loop prevention: if a workflow has an entity trigger AND creates the same entity type, add a condition to distinguish source entities from workflow-created ones. +- Validation runs automatically on write. Fix errors shown in WORKFLOW ERROR messages and retry. +- Some workflow files carry an `x-base44-migrated-from-automation` marker naming a `legacy_payload_function`. The step calling that function is on a compatibility layer keyed to the function's **name** — while the step calls it, the function receives a legacy input shape. You can change the function's internal logic, but don't change how it consumes its input. To change its inputs or replace it, point the step at a **new** function (different name) written normally — the different name turns the layer off. Other and new steps are normal. + +## Examples + +> Envelope examples below are written as `write_file(path, envelope)`: create the file at `path` with the envelope as its content. + +### "Clean up old data every night at 2am" +1. Create backend function `base44/functions/cleanupOldData/entry.ts` +2. Create workflow: +```json +write_file("base44/workflows/Nightly Cleanup.jsonc", { + "name": "Nightly Cleanup", + "description": "Cleans up old data every night at 2am", + "trigger": { "config": { "trigger_type": "scheduled", "cron_expression": "0 2 * * *", "timezone": "<user_timezone>" } }, + "definition": { + "document": { "dsl": "1.0.0", "name": "nightly_cleanup", "version": "1.0", "namespace": "base44" }, + "do": [{ "cleanup": { "call": "invoke_backend_function", "with": { "function_name": "cleanupOldData" }, "then": "end" } }] + } +}) +``` + +### "When an order status changes to completed, send a confirmation and wait 3 days, then request a review" +1. Create two backend functions: `sendConfirmation` and `requestReview` +2. Create workflow: +```json +write_file("base44/workflows/Order Followup.jsonc", { + "name": "Order Followup", + "description": "Sends confirmation on order completion, then requests review after 3 days", + "trigger": { + "config": { "trigger_type": "entity", "entity_name": "orders", "events": ["update"] }, + "condition": "${ .trigger.data.status == \"completed\" and .trigger.old_data.status != \"completed\" }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "order_followup", "version": "1.0", "namespace": "base44" }, + "do": [ + { "send_confirmation": { "call": "invoke_backend_function", "with": { "function_name": "sendConfirmation", "args": { "order_id": "${ .trigger.entity_id }" } }, "then": "wait_period" } }, + { "wait_period": { "wait": "P3D", "then": "request_review" } }, + { "request_review": { "call": "invoke_backend_function", "with": { "function_name": "requestReview", "args": { "order_id": "${ .trigger.entity_id }" } }, "then": "end" } } + ] + } +}) +``` + +### "When someone messages in #general on Slack, log it" +1. Ensure Slack is connected (`list_connectors`, `initiate_connector_connection`) +2. Resolve "#general" to channel ID: create temp function, call Slack API, get "C0123ABC" +3. Create backend function: `logSlackMessage` +4. Create workflow with condition: +```json +write_file("base44/workflows/Slack Logger.jsonc", { + "name": "Slack Logger", + "description": "Logs messages from #general channel", + "trigger": { + "config": { "trigger_type": "connector", "integration_type": "slack", "events": ["message"] }, + "condition": "${ .trigger.data.event.channel == \"C0123ABC\" }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "slack_logger", "version": "1.0", "namespace": "base44" }, + "do": [{ "log_message": { "call": "invoke_backend_function", "with": { "function_name": "logSlackMessage", "args": { "text": "${ .trigger.data.event.text }", "user": "${ .trigger.data.event.user }" } }, "then": "end" } }] + } +}) +``` + +### "When someone starts a chat with the Support agent, create a ticket and notify the team" +1. Create two backend functions: `createSupportTicket` and `notifyTeam` +2. Create workflow: +```json +write_file("base44/workflows/Support Ticket.jsonc", { + "name": "Support Ticket", + "description": "Creates a ticket and notifies team when a support conversation starts", + "trigger": { + "config": { "trigger_type": "in_app_agent", "agent_name": "Support", "events": ["conversation_started"] } + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "support_ticket", "version": "1.0", "namespace": "base44" }, + "do": [ + { "create_ticket": { "call": "invoke_backend_function", "with": { "function_name": "createSupportTicket", "args": { "message": "${ .trigger.data.message.text }", "user_id": "${ .trigger.data.app_user.id }" } }, "then": "notify" } }, + { "notify": { "call": "invoke_backend_function", "with": { "function_name": "notifyTeam", "args": { "ticket_id": "${ .create_ticket.id }", "agent": "${ .trigger.data.event.agent_name }" } }, "then": "end" } } + ] + } +}) +``` + +### "When someone signs up or logs in with a password, record it in the audit log" +1. Create backend function: `onPasswordAuth` +2. Create workflow: +```json +write_file("base44/workflows/On Password Auth.jsonc", { + "name": "On Password Auth", + "description": "Records password signups and logins in the audit log", + "trigger": { + "config": { "trigger_type": "app_user_auth", "events": ["login", "signup"] }, + "condition": "${ .trigger.auth_method == \"password\" }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "on_password_auth", "version": "1.0", "namespace": "base44" }, + "do": [{ "write_audit": { "call": "invoke_backend_function", "with": { "function_name": "onPasswordAuth", "args": { "user_id": "${ .trigger.user_id }", "email": "${ .trigger.email }", "activity": "${ .trigger.event_type }" } }, "then": "end" } }] + } +}) +``` + +### "When the app is published for the first time, notify the team" +1. Create backend function: `onAppLaunched` +2. Create workflow: +```json +write_file("base44/workflows/On First Publish.jsonc", { + "name": "On First Publish", + "description": "Notifies the team when the app is published for the first time", + "trigger": { + "config": { "trigger_type": "app_publish", "events": ["published"] }, + "condition": "${ .trigger.is_first_publish }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "on_first_publish", "version": "1.0", "namespace": "base44" }, + "do": [{ "notify_team": { "call": "invoke_backend_function", "with": { "function_name": "onAppLaunched", "args": { "published_at": "${ .trigger.occurred_at }", "visibility": "${ .trigger.visibility }" } }, "then": "end" } }] + } +}) +``` + +### "When someone pays, send a thank-you email" +1. Create backend function: `sendPaymentThankYou`: as service role it calls `payments.listPurchases({ subscription_id })` when given one, else `({ checkout_id })`, and else, with neither id, re-fetches the payer from the provider's API by `transaction_id`; it emails that `buyer_email`, and sends nothing when no purchase matches +2. Create workflow: +```json +write_file("base44/workflows/On Payment.jsonc", { + "name": "On Payment", + "description": "Sends a thank-you email when a live payment succeeds", + "trigger": { + "config": { "trigger_type": "app_payment", "events": ["payment_succeeded"] }, + "condition": "${ .trigger.is_live }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "on_payment", "version": "1.0", "namespace": "base44" }, + "do": [{ "send_thank_you": { "call": "invoke_backend_function", "with": { "function_name": "sendPaymentThankYou", "args": { "checkout_id": "${ .trigger.checkout_id }", "subscription_id": "${ .trigger.subscription_id }", "transaction_id": "${ .trigger.provider_transaction_id }", "amount": "${ .trigger.amount }", "currency": "${ .trigger.currency }" } }, "then": "end" } }] + } +}) +``` + +### Refresh, then act on the findings — the start / wait / results pattern (any trigger) +Point the `ready` branch at whatever step consumes `${ .read.scan }` (its `findings`, `total_findings`, `risk_level`). +```json +"do": [ + { "scan": { "call": "app_security_scan", "with": { "action": "start" }, "then": "settle" } }, + { "settle": { "wait": "PT15M", "then": "read" } }, + { "read": { "call": "app_security_scan", "with": { "action": "results", "after": "${ .scan.requested_at }" }, "then": "route" } }, + { "route": { "switch": [ + { "ready": { "when": "${ .read.state == \"ready\" }", "then": "end" } }, + { "running": { "when": "${ .read.state == \"running\" }", "then": "settle_again" } }, + { "other": { "then": "end" } } + ] } }, + { "settle_again": { "wait": "PT5M", "then": "read_again" } }, + { "read_again": { "call": "app_security_scan", "with": { "action": "results", "after": "${ .scan.requested_at }" }, "then": "end" } } +] +``` diff --git a/skills/base44-platform-workflows/references/mobile-automations.md b/skills/base44-platform-workflows/references/mobile-automations.md new file mode 100644 index 0000000..66c7853 --- /dev/null +++ b/skills/base44-platform-workflows/references/mobile-automations.md @@ -0,0 +1,125 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 workflows: Legacy automations (mobile apps that predate workflows) + +<automations_capability> +You can manage automations to run backend functions automatically - on a schedule, when entity records change, when external services send webhook events, or when app users start in-app agent conversations. +Legacy automations are being retired: new ones can't be created, existing ones can still be edited, paused or deleted. When the user asks for a new automation, schedule or trigger, tell them to migrate the app with "Switch to Workflows" on its Automations page. + +<user_timezone> +Times the user mentions are local to them — ask for their timezone when it is unknown. + +IMPORTANT: When the user mentions times (e.g., "9am", "2pm", "midnight"), they mean times in THEIR timezone. +- All times you provide (one_time_date, ends_on_date, start_time) should be in the user's local time - they will be automatically converted to UTC. +- For cron_expression: Times in cron are in UTC, so you need to calculate the UTC equivalent based on the user's timezone. +</user_timezone> + +<managing_automations> +Automations are not files: update, toggle and delete the existing ones in the dashboard (Automations); new ones can't be created. A new schedule or trigger means migrating the app with "Switch to Workflows" on its Automations page, after which the `base44-platform-workflows` skill's workflows reference applies. The parameters below are the ones the dashboard accepts, named as the builder passes them. +</managing_automations> + +<automation_types> +**Scheduled Automations (automation_type="scheduled"):** +Run a function on a schedule. + +Simple schedules (schedule_type="simple"): +- Every 5 minutes: repeat_interval=5, repeat_unit="minutes" (minimum is 5 minutes) +- Hourly: repeat_interval=1, repeat_unit="hours" +- Daily at specific time: repeat_interval=1, repeat_unit="days", start_time="09:00" +- Weekly on specific days: repeat_unit="weeks", repeat_on_days=[1, 5], start_time="10:00" (0=Sunday, 6=Saturday) +- Monthly on specific day: repeat_unit="months", repeat_on_day_of_month=15, start_time="00:00" + +Cron schedules (schedule_type="cron"): +- Use standard cron: "minute hour day-of-month month day-of-week" +- Example: "*/5 * * * *" = every 5 minutes (minimum interval is 5 minutes) +- Example: "0 9 * * 1-5" = 9am every weekday + +One-time schedules (schedule_mode="one-time"): +- Run once: one_time_date="2024-01-15T10:00:00" + +End conditions: +- ends_type="never": Run indefinitely (default) +- ends_type="on": Run until a date (ends_on_date="2024-12-31T23:59:59") +- ends_type="after": Run N times (ends_after_count=10) + +**Entity Automations (automation_type="entity"):** +Run a function when entity records are created, updated, or deleted. + +Required: entity_name, event_types (array of: "create", "update", "delete") + +The function receives a payload with: +- event: { type, entity_name, entity_id } +- data: current entity data (null if payload_too_large is true) +- old_data: previous data (only for "update" events, null if payload_too_large is true) +- changed_fields: array of top-level field names that changed between old_data and data (e.g. ["status", "amount"]) +- payload_too_large: true if entity data exceeded 200KB and was omitted. Use Base44 SDK to fetch: `await base44.asServiceRole.entities[event.entity_name].get(event.entity_id)` + +Optional: trigger_conditions — pre-filter which entity events actually trigger execution. +Use when the user only wants to act on specific changes (e.g., only when status becomes "completed", or only when a specific field changed). + +Entity conditions evaluate against a wrapped object (this wrapping applies ONLY to entity automations, NOT connector automations): +{ + "data": { ...current entity fields... }, + "old_data": { ...previous entity fields (update events only)... }, + "changed_fields": ["field1", "field2", ...] +} + +Field paths MUST use the wrapping key as prefix: +- "data.<field>" — current entity value (e.g. "data.status") +- "old_data.<field>" — previous value before the update (e.g. "old_data.status") +- "changed_fields" — the list itself; use "contains" operator to check if a field changed + +Example — only fire when status changes to "completed": +trigger_conditions={ + "logic": "and", + "conditions": [ + {"field": "changed_fields", "operator": "contains", "value": "status"}, + {"field": "data.status", "operator": "equals", "value": "completed"} + ] +} + +**In-App Agent Automations (automation_type="in_app_agent"):** +Run a function when an app user starts a new conversation with an in-app agent. + +Fields: function_name, agent_name (exact configured agent name; pass "" to clear a specific agent and watch all), event_types (array; currently only "conversation_started") + +The function receives a payload with: +- automation: { id, type, name } +- event: { type, agent_name, conversation_id, message_id, channel } +- message: { text, has_files } +- app_user: { id } +- payload_too_large: true if the message text was omitted because it was too large + +**Connector Automations (automation_type="connector"):** +Run a function when a connected external service sends a webhook event (e.g., new calendar event, Google Drive file change). + +Fields: integration_type (e.g., "googlecalendar", "googledrive"), events (array of event names), function_name + +Each provider has a different payload structure and auth model. Always read the provider's own webhook documentation for its payload and auth model (`list_connectors` gives the integration type, scopes and connection state, not a handler guide) BEFORE editing the handler function. + +Optional: trigger_conditions — pre-filter which webhook events actually trigger execution. +Use when the user only wants to act on a subset of events (e.g., a specific Slack channel, a specific Drive folder). + +trigger_conditions format: +{ + "logic": "and", + "conditions": [ + {"field": "<dot.path>", "operator": "<op>", "value": "<target>"} + ] +} + +- "field": dot-path into the provider's RAW webhook payload. Do NOT prefix with "data." — unlike entity conditions, connector conditions evaluate directly against the webhook payload as received from the provider. + For example, for Slack: "event.channel", "event.text", "event.user" (NOT "data.event.channel"). + You MUST know the provider's webhook payload structure — consult the provider's public API docs to determine the correct field paths. +- "operator": equals | not_equals | gt | gte | lt | lte | contains | not_contains | starts_with | ends_with | in_list | not_in_list | exists | not_exists | is_empty | is_not_empty +- "value": the concrete value to match. When the user provides a human-readable name but the webhook payload carries an internal ID, resolve the name first: create a temporary backend function that uses the authorized connector to look up the ID, invoke it, then use the returned ID as the condition value. Never guess IDs. +</automation_types> + +<usage_guidelines> +1. Find existing automations and their IDs in the dashboard (Automations) +2. Pause/resume automations from the dashboard +3. Minimum interval for scheduled automations is 5 minutes +</usage_guidelines> + +</automations_capability> diff --git a/skills/base44-platform-workflows/references/web-tanstack.md b/skills/base44-platform-workflows/references/web-tanstack.md new file mode 100644 index 0000000..4ad1388 --- /dev/null +++ b/skills/base44-platform-workflows/references/web-tanstack.md @@ -0,0 +1,634 @@ +<!-- GENERATED FILE. DO NOT EDIT. Compiled from the Base44 builder's prompt sources; the + source commit is in /SOURCE_COMMIT at the repository root. Edits here are overwritten by + the next sync; change the source instead. --> +# Base44 workflows: Workflows (TanStack Start: steps call server routes) + +<workflows_capability> +Workflows: trigger-driven automated processes — one step (e.g. a scheduled function call) or complex (multi-step, `switch` branching, durable waits across hours/days, data threading, chained backend functions). For any "when X happens, do Y" / "every N hours/days, do Y", however many actions Y involves. + +<files_and_runs> +Workflow files live at `base44/workflows/<Name>.jsonc`; writing the file creates or updates the workflow, deleting it removes the workflow. Status and runs: `base44 workflows list --app-id <APP_ID>` and `base44 workflows runs --app-id <APP_ID> [--status failed]`, or the Workflows dashboard. +</files_and_runs> + +<workflow_building_blocks> +What workflows can be built from; the authoring guide that follows documents each in full. +## Workflow Building Blocks + +### Triggers (what starts a workflow) +- scheduled — Fires on a schedule — a cron cadence, a fixed interval, or a one-time future instant. +- entity — Fires when an entity record is created, updated, or deleted. +- connector — Fires on a real-time webhook event from an authorized connector (Slack, Google Calendar, Drive, …). +- in_app_agent — Fires when a conversation starts with an in-app agent. +- app_user_auth — Fires when an app user signs up or logs in. +- app_publish — Fires when the app is published from the builder. +- app_payment — Fires when a payment succeeds or is refunded, or a subscription is canceled, in the app's payment provider. +- webhook — Fires on an inbound POST to a reusable workspace webhook URL, from any source. Use it for a third-party event no connector trigger covers - don't connect that provider or poll it to get the event. + +### Activities (what a `call` step can do) +- app_security_scan — Security scan (same as the Security tab): `start` kicks it off, `results` reads the latest findings. +- compute_seconds_until — Computes the seconds from now until a target datetime — feed the result into a dynamic wait. +- invoke_server_route — Calls a route on the app's own server. + +### Task Types (how steps are structured) +- call — invoke a registered activity with `with` args, then transition via `then`. +- wait — pause for an ISO-8601 duration (durable, survives restarts), then continue. +- switch — branch on ordered jq conditions; the last case is the catch-all `${ true }`. +</workflow_building_blocks> + +<creating_workflows> +1. Read the authoring guide below. 2. Needed server routes first. 3. Connector triggers: authorize first. 4. Write `base44/workflows/<Name>.jsonc` (the envelope). 5. Auto-validation on save — fix anything reported as WORKFLOW ERROR. +Each write creates a new version; running workflows keep their starting version. Graph + run history: dashboard. +</creating_workflows> + +<debugging_workflows> +`base44 workflows runs --app-id <APP_ID> --status failed` lists failed runs with the failing task and its error; `base44 logs --function <name>` reads that function's own logs; the Workflows dashboard shows each run's steps. +</debugging_workflows> +</workflows_capability> + +# Workflow Authoring Guide + +> **Filtered guide** — documents ONLY triggers: scheduled, entity, connector, in_app_agent, app_user_auth, app_publish, app_payment, webhook; activities: app_security_scan, compute_seconds_until, invoke_server_route. Any other trigger or activity is NOT covered here: do not author with it from memory — call this tool again with the names you need. + +## File Convention + +Workflows are `.jsonc` files under `base44/workflows/`. +- Path: `base44/workflows/<WorkflowName>.jsonc` +- Existing workflows: `base44 workflows list --app-id <APP_ID>` (runs: `base44 workflows runs --app-id <APP_ID>`), or the Workflows dashboard +- Create or update a workflow by writing its file +- Each write creates a new version. Already-running workflows keep the version they started with. +- After a successful write, the workflow is immediately active — scheduled triggers start firing, entity/connector triggers start listening. +- The workflow appears in the code view sidebar under Workflows and in the Workflows dashboard. + +## Managing Workflow Status + +Change lifecycle status (activate / deactivate / archive / unarchive) in the Workflows dashboard or through the platform API (the `workflows` area of the API catalog) — NOT file edits. + +- Stop/pause/cancel all: refresh the list, deactivate every active workflow, and report failures before claiming success. +- Destructive broad/bulk workflows: encode user protections in the definition (not conversation memory), preview scope/count, and get approval before activation or active updates. + +## Envelope Format + +```json +{ + "name": "Human-Readable Workflow Name", + "description": "What this workflow does", + "trigger": { ... }, + "definition": { ... } +} +``` + +## CNCF Serverless Workflow v1.0 Definition + +```json +{ + "document": { "dsl": "1.0.0", "name": "snake_case_id", "version": "1.0", "namespace": "base44" }, + "do": [ + { "task_name": { "call": "activity_name", "with": {}, "then": "next_task" } } + ] +} +``` + +**Task naming**: must be snake_case (letters, digits, underscores). Task results are accessed in jq as `${ .task_name.field }` — hyphens break jq field access. Never start a task name with `_` — that namespace is reserved for the system and the save will be rejected. + +**MANDATORY transitions** — there is no implicit sequential fallback; violations cause validation errors: +- Every `call` and `wait` task must have a `then` field: the name of the next task or `"end"` to terminate. +- `switch` tasks branch ONLY through their cases: every case must have its own `then`, the **last case must be the catch-all `{"when": "${ true }", "then": ...}`** ("otherwise") so every run takes an explicit, visible branch, and the switch task itself must NOT have a task-level `then` — with an exhaustive case list it would be dead code. +- A switch needs at least one real condition case besides the catch-all. A switch whose only case is `${ true }` is a no-op and is rejected. + +**Execution model: strictly sequential.** Tasks run one at a time in `then` order — there is NO parallel execution, fork/join, or "branches running at the same time". Never fabricate "fan-out" / "converge" marker nodes (e.g. a pass-through switch) to make the graph look parallel: the graph would misrepresent what actually runs. If the user asks for parallel branches, explain that steps execute in sequence and model the flow as ordered steps. + +## Supported Task Types + +### call — invoke a registered activity +```json +{ "fetch_data": { "call": "invoke_server_route", "with": { "path": "/api/get-data", "body": {} }, "then": "next_task" } } +``` + +### switch — conditional branching +```json +{ "check": { "switch": [ + { "when": "${ .fetch_data.status == \"active\" }", "then": "handle_active" }, + { "when": "${ true }", "then": "end" } +] } } +``` +Cases are evaluated in order — the first truthy `when` wins (transition rules above). + +### wait — durable time delay (survives restarts) +```json +{ "pause": { "wait": "PT5M", "then": "next_task" } } +``` +ISO 8601: PT5M = 5 min, PT1H = 1 hour, P1D = 1 day, P3DT12H = 3.5 days + +## `x-base44` (display-only node labels - set on every task) + +Set `x-base44` on every task for graph labels only — it never changes runtime behavior, task output, transitions, or activity inputs. Allowed sub-fields (anything else is rejected): `title` (node title — **always in the user's language**; the task key stays `snake_case` ASCII) and optional `description` (very short subtitle). + +```json +{ "fetch_data": { "call": "invoke_server_route", "with": { "path": "/api/get-data" }, "then": "next_task", "x-base44": { "title": "Fetch order", "description": "Latest record" } } } +``` + +When editing, preserve `x-base44` where the task's purpose is unchanged; update `title`/`description` when the meaning changes — no stale labels. + +## Available Activities + +### app_security_scan +The app's security scan — the same scan as the builder's Security tab (RLS, secrets, backend functions, dependencies, static code). `action: start` starts a scan and returns at once, without findings. `action: results` reads the latest completed scan. +- When to use: Whenever a workflow needs the app's security posture or findings, on any trigger — to refresh them (`start`) or to read and act on them (`results`). A scan runs in the background for up to ~10 minutes, so `start` never returns findings: `wait` at least PT10M, then `results` passing `after: ${ .<start step>.requested_at }` so an older scan is reported as `pending`, not as ready. If `results` says `running`, wait PT5M and read once more. `results` alone is enough when the latest scan will do. Branch on `state` — only `ready` carries `scan`. Zero findings means clean only when `scan.status` is `up_to_date` and `scan.rls_scan_failed` is false. `scan.findings` is capped: severity-first, 50 by default — pass `max_findings` (up to 100) to raise it, check `findings_truncated`, and take the count from `total_findings`, never from the list length. If nothing changed since the last scan, `start` itself returns `ready` with that scan. Run now behaves like any other run: `start` really starts a scan. +- `action`: `start` to start a scan, or `results` to read the latest completed scan. Required. +- `after`: results only, optional. ISO-8601 datetime; a scan completed before it is reported as `pending` rather than `ready`. Pass the `requested_at` returned by the `start` step. +- `max_findings`: results only, optional. How many findings to include in `scan.findings`, severity-first (default 50, max 100); `scan.findings_truncated` says whether any were left out. +- Returns: {"action": "start" | "results", "state": str, "scan": {...} | null, "requested_at": str (start only; the `after` for a follow-up results — when state is "ready" it is the returned scan's own scanned_at, so results accepts it)}. start states: "started" (scan spawned), "running" (a scan is already in flight), "ready" (nothing changed since the last scan; `scan` is current). results states: "ready" (`scan` set), "running", "pending" (latest scan predates `after`), "failed" (last scan failed), "not_scanned". `scan` is set only when state is "ready": {"scanned_at": str, "status": "up_to_date" | "out_of_date" (as the Security tab shows it), "rls_scan_failed": bool (RLS section did not complete — empty RLS findings are not a clean result), "risk_level": str, "total_findings": int, "counts_by_severity": {severity: int}, "counts_by_category": {category: {severity: int}}, "finding_categories": [str], "findings": [{"category": "rls" | "secrets" | "backend_functions" | "dependencies" | "code" | "core_integration", "severity": str, "title": str, "description": str, "file_path"?: str, "line"?: int, "recommendation"?: str, "package_name"?: str, "current_version"?: str, "fixed_version"?: str, "vulnerability_id"?: str}], "findings_truncated": bool}. `start` never returns findings (its `scan.findings` is empty). + +### compute_seconds_until +Compute seconds from now until a target datetime. +- `target_iso`: ISO 8601 datetime string +- Returns: {"seconds": float, "target_iso": str, "now_iso": str} + +### invoke_server_route +Call a route served by the app's own full-stack server. +- When to use: Logic you write as code in one of the app's server routes: API calls, data fetching/transforms, entity CRUD, sending email/HTTP — no agent reasoning turn. A path that matches no route reaches the app's catch-all instead of failing, so copy it from the route file rather than guessing. +- `path`: App-relative route path, starting with '/' (e.g. '/api/orders/sync') +- `method`: HTTP method — GET, POST, PUT, PATCH or DELETE (default POST) +- `body`: Dict sent as the JSON request body (supports jq expressions) +- `query`: Dict of query-string parameters (supports jq expressions) +- Returns: Parsed JSON response from the route + +## Choosing Between Activities + +See each activity's "When to use" line above for what it's best at. + +## jq Expression Language + +Use `${ .field }` syntax (CNCF SWF v1.0). +- Trigger data: `${ .trigger.data.fieldname }` +- Task output: `${ .task_name.field }` +- Conditions: `${ .trigger.data.status == "active" }` +- String interpolation: `"Hello ${ .trigger.data.name }"` + +**Write jq, not JavaScript/Python.** Common mistakes to avoid: +- Logic operators are `and` / `or` / `not`, never `&&` / `||` / `!`: `${ .trigger.data.a == 1 and .trigger.data.b != 2 }`. +- Membership is `${ .trigger.data.event.channel | IN("C1","C2") }`, never infix `x in [...]`. +- String tests (`test` / `contains` / `startswith` / `ascii_downcase`) throw on a null field — default first: `${ (.trigger.data.subject // "") | test("receipt"; "i") }`. +- Iteration throws on null — default with `// []`: `${ any((.trigger.data.items // [])[]; .qty > 0) }`. +- In a pipe, `.` is the piped value, not the root — bind root refs first: `${ .trigger.data.event.channel as $c | ["C1","C2"] | index($c) != null }`. + +### Keep jq minimal + +jq only reads the payload (trigger data + prior task outputs): field access, equality, simple boolean/arithmetic checks. Anything more — transformation, computation, lookups, multi-step branching — belongs in a server route (`invoke_server_route`), not in elaborate jq. +- Never use `env` / `$ENV`, `input`, `def`, `range`, `recurse`, `reduce`, `foreach`, `walk`, `..` — the save is rejected. jq cannot reach app secrets; only a server route can read and use an API key or signing secret. +- Good: `${ .trigger.data.status == "approved" }`. Bad: `${ env.API_KEY }`. + +## Trigger Types + +The trigger object wraps the type-specific fields (shown below) in `config`, plus an optional jq `condition` that must be truthy for the workflow to run: +```json +{ "config": { "trigger_type": "<type>", <type-specific fields — see your trigger's section> }, "condition": "${ <jq predicate over .trigger, optional> }" } +``` + +### scheduled +```json +{ "trigger_type": "scheduled", "cron_expression": "0 9 * * 1-5", "timezone": "America/New_York" } +``` + +**Webhook vs polling**: when the user wants to react to external events (calendar changes, new messages, CRM updates), prefer a connector trigger over scheduled polling — connector triggers deliver events in real time. Use scheduled polling only when no connector supports webhooks for the service (check the connector's details with `list_connectors`). + +**Timezone**: Ask the user for their timezone when it is not known. When the user mentions times (e.g., "9am", "2pm", "midnight"), they mean times in THEIR timezone. Set the `timezone` field to the user's timezone — the cron expression is then evaluated in that timezone (no manual UTC conversion needed). + +Cron format: `minute hour day-of-month month day-of-week` + +**Choose cron vs interval FIRST.** Cron fires at clock-aligned times, so a "every N <unit>" request is only expressible in cron when N evenly divides its field's wrap period — minutes where N divides 60 (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30) or hours where N divides 24 (1, 2, 3, 4, 6, 8, 12). For any other fixed gap — "every 90 minutes", "every 45 minutes", "every 7 hours", "every 2 days" — cron CANNOT express it: use **interval mode** (see below). Never approximate with `*/90 * * * *` (most cron parsers reduce the out-of-range minute step to :00, collapsing it to hourly — some reject it outright) or `0 9 */2 * *` (the day-of-month step restarts every month, so it is not a true 2-day gap). + +Translating user requests to cron (clock-aligned cadences): +- "every 5 minutes" → `*/5 * * * *` (minimum interval) +- "every hour" / "hourly" → `0 * * * *` +- "every day at 9am" / "daily at 9" → `0 9 * * *` +- "every weekday at 9am" / "Monday to Friday" → `0 9 * * 1-5` +- "every Monday at 10am" → `0 10 * * 1` +- "every Monday and Friday at 10am" → `0 10 * * 1,5` +- "every month on the 15th" → `0 0 15 * *` +- "every month on the 1st at 9am" → `0 9 1 * *` +- "twice a day at 9am and 5pm" → `0 9,17 * * *` +- "every 30 minutes during business hours" → `*/30 9-17 * * 1-5` +- "every Sunday at midnight" → `0 0 * * 0` + +**Run once** (instead of recurring): set `schedule_mode` to `"one_time"` with a future UTC instant — no cron_expression needed: +```json +{ "trigger_type": "scheduled", "schedule_mode": "one_time", "one_time_date": "2026-07-01T16:00:00Z" } +``` +`one_time_date` is a future UTC instant. Never guess "now" — take the current local time from the user, resolve the user's request against it (relative "in N minutes" or a clock time like "9:12am"), then convert to UTC. + +**Recurring at a fixed interval** — use this for any "every N minutes/hours/days" the cron rule above says cron can't express (e.g. "every 90 minutes", "every 45 minutes", "every 7 hours", "every 2 days"). Set `schedule_mode` to `"interval"` and DO NOT set `cron_expression`: +```json +{ "trigger_type": "scheduled", "schedule_mode": "interval", "interval_value": 2, "interval_unit": "days", "interval_anchor": "2026-07-01T09:00:00Z" } +``` +Units: minutes | hours | days only. Minimum interval is 5 minutes. `interval_anchor` (UTC) sets the phase — when the user gives a time-of-day or start ("every 2 days at 9am", "every 90 minutes starting 14:00") set it to that UTC instant; omit it to align to the epoch. For calendar cadences (specific weekdays, day-of-month, weekly, monthly) use cron. + +**End conditions** (recurring schedules only, default is run forever): +- Stop on a date: add `"ends_type": "on_date", "ends_on_date": "2026-12-31T23:59:00Z"` — the last instant the schedule may fire (UTC). +- Stop after N runs: add `"ends_type": "after_count", "ends_after_count": 10`. + +When a schedule finishes (one-time fired, end date passed, or run count reached) the workflow is automatically archived. + +### entity +```json +{ "trigger_type": "entity", "entity_name": "orders", "events": ["create", "update"] } +``` + +Trigger payload structure: +- `${ .trigger.entity_name }` — entity name (e.g. "orders") +- `${ .trigger.entity_id }` — the entity record ID +- `${ .trigger.event_type }` — "create", "update", or "delete" +- `${ .trigger.data.* }` — current entity data +- `${ .trigger.old_data.* }` — previous data (update events only) +- `${ .trigger.changed_fields }` — array of top-level field names that changed +- `${ .trigger.payload_too_large }` — true when the record was too large to attach (see note below) + +Condition examples: +- Only when status changes to "completed": + `"condition": "${ .trigger.data.status == \"completed\" and .trigger.old_data.status != \"completed\" }"` +- Only when a specific field changed: + `"condition": "${ .trigger.changed_fields | index(\"status\") != null }"` +- Only for a specific entity value: + `"condition": "${ .trigger.data.priority == \"high\" }"` + +Note: when the entity record is very large (over ~256KB), `data` and `old_data` arrive as null and `payload_too_large` is true (`changed_fields` stays populated). Trigger conditions still evaluate against the full data, but the server route must re-fetch the record by id using the Base44 SDK: `await base44.asServiceRole.entities["entity_name"].get(entity_id)`. + +Use `read_file("base44/entities/<EntityName>.jsonc")` to check entity field names before writing conditions. + +### connector +```json +{"trigger_type": "connector", "integration_type": "slack", "events": ["message"]} +``` + +Optional: `resource_id` — for Google Calendar (calendar ID, e.g. "primary" or "user@gmail.com") or Google Drive (file ID). Controls which webhook registration is used (scopes the provider subscription, not per-workflow dispatch filtering). +```json +{"trigger_type": "connector", "integration_type": "googlecalendar", "events": ["events"], "resource_id": "primary"} +``` + +Prerequisites: +- The connector must be authorized (use the Base44 MCP connector tools `list_connectors` and `initiate_connector_connection`, or `base44 connectors`). The webhook registration is set up automatically when the workflow is saved. +- Each provider has a different payload structure and auth model. Read the provider's own webhook documentation for its payload and auth model (`list_connectors` gives the integration type, scopes and connection state, not a handler guide) BEFORE writing the server route. + +Trigger payload: +- `${ .trigger.integration_type }` — e.g. "slack", "googlecalendar" +- `${ .trigger.event_type }` — the webhook event type +- `${ .trigger.data.* }` — the raw webhook payload from the provider +- `${ .trigger.payload_too_large }` — true when the webhook payload was too large to attach (over ~256KB); `data` arrives as null (conditions still evaluate against the full payload) and the server route must re-fetch the resource from the provider + +Condition field paths use the provider's webhook payload structure: +- Slack: `${ .trigger.data.event.channel == \"C0123ABC\" }`, `${ .trigger.data.event.user == \"U0123ABC\" }` +- Google Calendar: `${ .trigger.data.resource_id == \"calendar_id\" }` + +**Important**: When the user provides a human-readable name (e.g. "#general"), the webhook payload uses internal IDs. Resolve the name to an ID first: create a temporary server route that uses the authorized connector to look up the ID, request it once, then use the returned ID in the condition. Never guess IDs. + +High-volume connectors (e.g. Slack) require conditions — validation will reject triggers without them. + +If activation fails with `MISSING_WEBHOOK_SCOPES`, re-connect that connector (`initiate_connector_connection`, or `base44 connectors initiate`) with the union of its granted and missing scopes (re-authorization replaces the grant, so omitting the granted ones drops them), then save the workflow again. + +Available connectors: + - gmail: mailbox + - googlecalendar: events + - googledrive: changes, file, file.update, file.trash, file.untrash, file.delete + - microsoft_teams: created, updated, deleted + - one_drive: updated + - outlook: created, updated, deleted + - share_point: updated + - slack: message, message.im, message.groups, message.channels, message.mpim, reaction_added, reaction_removed, member_joined_channel, member_left_channel, file_shared + - slackbot: app_mention, message.im, message.mpim, message.channels, message.groups + - wix: wix.forms.v4.submission_created, wix.contacts.v4.contact_created, wix.contacts.v4.contact_updated, wix.contacts.v4.contact_deleted, wix.ecom.v1.order_created, wix.ecom.v1.order_updated, wix.ecom.v1.order_canceled, wix.ecom.v1.checkout_created, wix.data.v2.data_item_created, wix.data.v2.data_item_updated, wix.data.v2.data_item_deleted + +### in_app_agent +```json +{ "trigger_type": "in_app_agent", "agent_name": "support_agent", "events": ["conversation_started"] } +``` +Omit `agent_name` to trigger for all in-app agents. `events` is required — currently only `"conversation_started"` is supported. + +Trigger payload structure: +- `${ .trigger.agent_name }` — the agent name (top-level shorthand) +- `${ .trigger.data.event.type }` — always "conversation_started" +- `${ .trigger.data.event.agent_name }` — agent that received the message +- `${ .trigger.data.event.conversation_id }` — conversation ID +- `${ .trigger.data.event.message_id }` — first message ID +- `${ .trigger.data.event.channel }` — channel (e.g. "web", "mobile") +- `${ .trigger.data.message.text }` — first message text +- `${ .trigger.data.message.has_files }` — whether files were attached +- `${ .trigger.data.app_user.id }` — app user ID +- `${ .trigger.payload_too_large }` — true when the first message was too large to attach (over ~256KB); `data.message` arrives as null but the event IDs remain, so fetch the conversation by `data.event.conversation_id` + +### app_user_auth +```json +{ "trigger_type": "app_user_auth", "events": ["signup", "login"] } +``` + +app_user_auth fields live on `.trigger` directly — do NOT use `.trigger.data.*` for this trigger. + +Trigger payload fields (use `${ .trigger.<field> }` in conditions and `do` args): +- `${ .trigger.event_type }` — "signup" or "login" +- `${ .trigger.auth_method }` — "password", "google", "microsoft", "facebook", "apple", "app_sso", or "workspace_sso" +- `${ .trigger.occurred_at }` — ISO timestamp for the successful auth transaction +- `${ .trigger.user_id }` — app-user ID string (preferred for server-route body) +- `${ .trigger.email }` — app-user email +- `${ .trigger.full_name }` — app-user display name +- `${ .trigger.role }` — app-user role +- `${ .trigger.app_user.id }` — same ID as user_id +- `${ .trigger.app_user.collaborator_role }` — collaborator role, when present +- `${ .trigger.app_user.is_verified }` — whether the app user is verified + +Canonical `invoke_server_route` body (same for signup and login): +```json +"body": { + "user_id": "${ .trigger.user_id }", + "email": "${ .trigger.email }", + "auth_method": "${ .trigger.auth_method }" +} +``` +Do NOT pass `${ .trigger.app_user }` as user_id — it is an object, not a string. + +When each event fires (depends on whether the app is public or private): + +**Public apps:** +- **signup** — the end user's first successful sign-in (email/password or social login). +- **login** — every later successful sign-in. + +**Private or workspace apps:** +- **signup** — when an admin approves a new user's access request. Does not fire on first login. +- **login** — every successful sign-in after the user exists, including the first sign-in after approval. + +Condition examples: +- Only password auth: + `"condition": "${ .trigger.auth_method == \"password\" }"` +- Only Google signups: + `"condition": "${ .trigger.event_type == \"signup\" and .trigger.auth_method == \"google\" }"` +- Only workspace SSO logins: + `"condition": "${ .trigger.auth_method == \"workspace_sso\" }"` +- Only admin app users: + `"condition": "${ .trigger.role == \"admin\" }"` + +### app_publish +```json +{ "trigger_type": "app_publish", "events": ["published"] } +``` + +app_publish fields live on `.trigger` directly — do NOT use `.trigger.data.*` for this trigger. + +Trigger payload fields (use `${ .trigger.<field> }` in conditions and `do` args): +- `${ .trigger.event_type }` — always "published" +- `${ .trigger.occurred_at }` — ISO timestamp of the publish +- `${ .trigger.is_first_publish }` — true only on the app's first-ever publish +- `${ .trigger.visibility }` — published visibility ("private_with_login", "workspace_with_login", "public_with_login", or "public_without_login") +- `${ .trigger.published_by }` — platform user ID of the publisher, null for API-key publishes + +Fires on every successful publish, including restoring a previous version. Does not fire on unpublish. + +Condition examples: +- Only the first publish (launch): + `"condition": "${ .trigger.is_first_publish }"` + +### app_payment +```json +{ "trigger_type": "app_payment", "events": ["payment_succeeded"] } +``` + +Requires a connected payment provider (Stripe, PayPal, Wix Payments, or Payments by Wix): activating a workflow with this trigger fails when none is connected. Connect a provider first, then create the workflow. The payload is normalized across providers; workflows never receive provider-shaped raw data. + +Valid events: `payment_succeeded` (a successful payment: a completed checkout and the first payment of a subscription), `payment_refunded` (an approved refund, including one made from the Payments dashboard or the provider's own site), `subscription_canceled` (a subscription stopped billing: canceled or ended on Stripe and PayPal, and paused/suspended on PayPal only, e.g. by the buyer in PayPal or after failed payments; a Stripe pause fires nothing; a PayPal subscription resumed outside the app fires nothing and `listPurchases` can't show it, so tell the user to restore that access by hand). Failed payment attempts do NOT fire this trigger. + +Use `payment_refunded` and `subscription_canceled` to revoke what a purchase granted: match the event to the purchase the app saved by whichever of `subscription_id`, `checkout_id` and `payment_id` it carries, and make the revoke idempotent. + +Recurring subscription renewal charges are wired to fire `payment_succeeded` too, but delivery is not enabled yet for every Stripe account. Do not build a workflow whose only purpose is reacting to renewals, and tell the user renewals may not fire yet if they ask for one. The same holds for `subscription_canceled` on Stripe; on PayPal both fire. + +When renewals do arrive, the payload does not distinguish them from a first subscription payment — both are `payment_succeeded` with `mode: "subscription"`. So do not use this trigger for one-time-only actions on a subscription (welcome emails, provisioning, granting access) without your own guard, e.g. an entity record keyed by `provider_customer_id` that the workflow checks and writes. + +app_payment fields live on `.trigger` directly — do NOT use `.trigger.data.*` for this trigger. + +Trigger payload fields (use `${ .trigger.<field> }` in conditions and `do` args): +- `${ .trigger.event_type }`: "payment_succeeded", "payment_refunded" or "subscription_canceled" +- `${ .trigger.provider }`: "stripe", "paypal", "wix_payments", or "payments_by_wix" +- `${ .trigger.amount }`: integer amount in cents for most currencies (e.g. 2500 = $25.00), matching the platform's payment analytics representation; 0 on `subscription_canceled` +- `${ .trigger.currency }` — lowercase ISO currency code (e.g. "usd") +- `${ .trigger.is_live }` — false for test/sandbox payments; test payments DO fire this trigger +- `${ .trigger.mode }`: "payment" (one-off checkout) or "subscription" (a subscription charge, first payment OR renewal, indistinguishable); null on refunds. Only Stripe and PayPal distinguish these; Wix payments always report "payment" +- `${ .trigger.occurred_at }` — ISO timestamp of the provider event +- `${ .trigger.provider_customer_id }` — provider-side customer/contact ID, or null +- `${ .trigger.provider_transaction_id }`: provider-side transaction/session/refund ID, for logs; look purchases up by the ids below instead +- `${ .trigger.provider_event_id }` — provider webhook event ID (webhook redeliveries are already deduped on it — don't build your own guard) +- `${ .trigger.checkout_id }`, `${ .trigger.subscription_id }`, `${ .trigger.payment_id }`: the same ids `base44.payments` returns on a purchase; each is null when the provider does not name it on that event (Wix events carry none; a Stripe renewal carries only `subscription_id`; a refund carries `payment_id`, plus `subscription_id` when it refunds a subscription charge) +- `${ .trigger.subscription_status }`: on `subscription_canceled`: "canceled" (canceled or ended) or "paused" (can resume); null otherwise + +The payload carries no payer email or other PII. When needed, look the purchase up in a server route, as service role, with `payments.listPurchases({ subscription_id })` when the event names one, else `({ checkout_id })`, which returns `buyer_email`. Never call `listPurchases` with neither id: unscoped it lists every buyer's purchases. Events that carry neither (Wix, or a Stripe payment made outside a checkout) are re-fetched by `provider_transaction_id` from the provider's API instead. Zero-amount payments and refunds (free checkouts, 100% discounts) do not fire. + +Condition examples: +- Only real (live-mode) payments: + `"condition": "${ .trigger.is_live }"` +- Only large payments (over $100): + `"condition": "${ .trigger.amount > 10000 }"` + +### webhook + +A workspace webhook must already exist — this trigger listens on one, it does +not create one. Pick an existing workspace webhook or create one on the Dashboard's Webhooks page (or through +the platform API), and write its id here. Reuse an existing +one where you can: many providers allow only a single webhook URL per account, +so a second listener for the same provider usually cannot be wired up at all. + +```json +{ "trigger_type": "webhook", "webhook_id": "<id from the tool>", + "webhook_name": "hubspot-events", "events": ["contact.creation"] } +``` + +`webhook_id` is the reference — never a URL and never a token. You cannot guess +or invent it: it only exists once a tool hands it to you, and an id that is not +in this workspace blocks activation with `WEBHOOK_NOT_FOUND`. + +`webhook_name` is the listener's display name at authoring time. It is carried for +readability only and is never resolved against — the user may rename the +listener, which changes the label and nothing else. + +One webhook URL commonly carries several event types. `events` filters which +ones run this workflow; an empty list subscribes to all of them. Reusing a +webhook across workflows is normal and preferred — many providers allow only +one webhook URL per account. Only set `events` when the webhook classifies +event types; otherwise activation fails with `WEBHOOK_HAS_NO_EVENT_TYPES`. + +Trigger payload (use `${ .trigger.* }` in conditions and `do` args): +- `${ .trigger.data.<field> }` — the parsed request body, e.g. `${ .trigger.data.properties.email }` +- `${ .trigger.event_type }` — the event type this request was classified as +- `${ .trigger.webhook_name }` — the webhook's display name (may have changed since authoring) +- `${ .trigger.webhook_id }` — the webhook's id, stable across renames +- `${ .trigger.headers.<name> }` — request headers, lowercased, credentials removed +- `${ .trigger.query.<name> }` — query-string params, credentials removed + +`${ .trigger.data }` arrives as `null` when the body was too large to forward. +Unlike an entity trigger there is nothing to re-fetch — the body is never +persisted — so a workflow that must tolerate big payloads should branch on the +absence rather than assume the field is there. + +Condition example — only enterprise-tier contacts: +`"condition": "${ .trigger.data.properties.tier == \"enterprise\" }"` + +## Designing Steps: When to Split vs Combine + +**Split** into separate tasks when operations are independent (a failure shouldn't re-run the others), a delay sits between them, or you want per-step visibility and retry granularity in the run log. **Combine** into one step when operations share setup/auth/transaction, must be atomic, or the second needs the first's full in-memory result. + +**Conditions**: use a `switch` when the outcome picks the path (skip steps, branch, end early — visible in the graph); keep validation/error handling inside the step. Rule of thumb: a flowchart diamond goes in the workflow; an implementation if-statement stays in the step. + +## Important Notes + +- Create the server route BEFORE the workflow that calls it. +- A step calls the server route as the workflow's creator, an app admin, but its URL is public. One only workflows call must set `server: { middleware: [requireUserRoute], handlers }` from `@/lib/auth-middleware`, return 403 unless `context.user.role === "admin"`, then use `context.getBase44().asServiceRole`; never a shared secret. One the app's users also call keeps its usual checks. +- Never put a secret value (API key, token, password) in a workflow — not in step args, conditions or anywhere else. The server route reads the secret from the app's secrets itself. +- Infinite loop prevention: if a workflow has an entity trigger AND creates the same entity type, add a condition to distinguish source entities from workflow-created ones. +- Validation runs automatically on write. Fix errors shown in WORKFLOW ERROR messages and retry. +- Some workflow files carry an `x-base44-migrated-from-automation` marker naming a `legacy_payload_function`. The step calling that function is on a compatibility layer keyed to the function's **name** — while the step calls it, the function receives a legacy input shape. You can change the function's internal logic, but don't change how it consumes its input. To change its inputs or replace it, point the step at a **new** function (different name) written normally — the different name turns the layer off. Other and new steps are normal. + +## Examples + +> Envelope examples below are written as `write_file(path, envelope)`: create the file at `path` with the envelope as its content. + +### "Clean up old data every night at 2am" +1. Add server route: `POST /api/cleanup-old-data` +2. Create workflow: +```json +write_file("base44/workflows/Nightly Cleanup.jsonc", { + "name": "Nightly Cleanup", + "description": "Cleans up old data every night at 2am", + "trigger": { "config": { "trigger_type": "scheduled", "cron_expression": "0 2 * * *", "timezone": "<user_timezone>" } }, + "definition": { + "document": { "dsl": "1.0.0", "name": "nightly_cleanup", "version": "1.0", "namespace": "base44" }, + "do": [{ "cleanup": { "call": "invoke_server_route", "with": { "path": "/api/cleanup-old-data" }, "then": "end" } }] + } +}) +``` + +### "When an order status changes to completed, send a confirmation and wait 3 days, then request a review" +1. Add two server routes: `POST /api/send-confirmation` and `POST /api/request-review` +2. Create workflow: +```json +write_file("base44/workflows/Order Followup.jsonc", { + "name": "Order Followup", + "description": "Sends confirmation on order completion, then requests review after 3 days", + "trigger": { + "config": { "trigger_type": "entity", "entity_name": "orders", "events": ["update"] }, + "condition": "${ .trigger.data.status == \"completed\" and .trigger.old_data.status != \"completed\" }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "order_followup", "version": "1.0", "namespace": "base44" }, + "do": [ + { "send_confirmation": { "call": "invoke_server_route", "with": { "path": "/api/send-confirmation", "body": { "order_id": "${ .trigger.entity_id }" } }, "then": "wait_period" } }, + { "wait_period": { "wait": "P3D", "then": "request_review" } }, + { "request_review": { "call": "invoke_server_route", "with": { "path": "/api/request-review", "body": { "order_id": "${ .trigger.entity_id }" } }, "then": "end" } } + ] + } +}) +``` + +### "When someone messages in #general on Slack, log it" +1. Ensure Slack is connected (`list_connectors`, `initiate_connector_connection`) +2. Resolve "#general" to channel ID: create temp function, call Slack API, get "C0123ABC" +3. Add server route: `POST /api/log-slack-message` +4. Create workflow with condition: +```json +write_file("base44/workflows/Slack Logger.jsonc", { + "name": "Slack Logger", + "description": "Logs messages from #general channel", + "trigger": { + "config": { "trigger_type": "connector", "integration_type": "slack", "events": ["message"] }, + "condition": "${ .trigger.data.event.channel == \"C0123ABC\" }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "slack_logger", "version": "1.0", "namespace": "base44" }, + "do": [{ "log_message": { "call": "invoke_server_route", "with": { "path": "/api/log-slack-message", "body": { "text": "${ .trigger.data.event.text }", "user": "${ .trigger.data.event.user }" } }, "then": "end" } }] + } +}) +``` + +### "When someone starts a chat with the Support agent, create a ticket and notify the team" +1. Add two server routes: `POST /api/create-support-ticket` and `POST /api/notify-team` +2. Create workflow: +```json +write_file("base44/workflows/Support Ticket.jsonc", { + "name": "Support Ticket", + "description": "Creates a ticket and notifies team when a support conversation starts", + "trigger": { + "config": { "trigger_type": "in_app_agent", "agent_name": "Support", "events": ["conversation_started"] } + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "support_ticket", "version": "1.0", "namespace": "base44" }, + "do": [ + { "create_ticket": { "call": "invoke_server_route", "with": { "path": "/api/create-support-ticket", "body": { "message": "${ .trigger.data.message.text }", "user_id": "${ .trigger.data.app_user.id }" } }, "then": "notify" } }, + { "notify": { "call": "invoke_server_route", "with": { "path": "/api/notify-team", "body": { "ticket_id": "${ .create_ticket.id }", "agent": "${ .trigger.data.event.agent_name }" } }, "then": "end" } } + ] + } +}) +``` + +### "When someone signs up or logs in with a password, record it in the audit log" +1. Add server route: `POST /api/on-password-auth` +2. Create workflow: +```json +write_file("base44/workflows/On Password Auth.jsonc", { + "name": "On Password Auth", + "description": "Records password signups and logins in the audit log", + "trigger": { + "config": { "trigger_type": "app_user_auth", "events": ["login", "signup"] }, + "condition": "${ .trigger.auth_method == \"password\" }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "on_password_auth", "version": "1.0", "namespace": "base44" }, + "do": [{ "write_audit": { "call": "invoke_server_route", "with": { "path": "/api/on-password-auth", "body": { "user_id": "${ .trigger.user_id }", "email": "${ .trigger.email }", "activity": "${ .trigger.event_type }" } }, "then": "end" } }] + } +}) +``` + +### "When the app is published for the first time, notify the team" +1. Add server route: `POST /api/on-app-launched` +2. Create workflow: +```json +write_file("base44/workflows/On First Publish.jsonc", { + "name": "On First Publish", + "description": "Notifies the team when the app is published for the first time", + "trigger": { + "config": { "trigger_type": "app_publish", "events": ["published"] }, + "condition": "${ .trigger.is_first_publish }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "on_first_publish", "version": "1.0", "namespace": "base44" }, + "do": [{ "notify_team": { "call": "invoke_server_route", "with": { "path": "/api/on-app-launched", "body": { "published_at": "${ .trigger.occurred_at }", "visibility": "${ .trigger.visibility }" } }, "then": "end" } }] + } +}) +``` + +### "When someone pays, send a thank-you email" +1. Add server route: `POST /api/send-payment-thank-you`: as service role it calls `payments.listPurchases({ subscription_id })` when given one, else `({ checkout_id })`, and else, with neither id, re-fetches the payer from the provider's API by `transaction_id`; it emails that `buyer_email`, and sends nothing when no purchase matches +2. Create workflow: +```json +write_file("base44/workflows/On Payment.jsonc", { + "name": "On Payment", + "description": "Sends a thank-you email when a live payment succeeds", + "trigger": { + "config": { "trigger_type": "app_payment", "events": ["payment_succeeded"] }, + "condition": "${ .trigger.is_live }" + }, + "definition": { + "document": { "dsl": "1.0.0", "name": "on_payment", "version": "1.0", "namespace": "base44" }, + "do": [{ "send_thank_you": { "call": "invoke_server_route", "with": { "path": "/api/send-payment-thank-you", "body": { "checkout_id": "${ .trigger.checkout_id }", "subscription_id": "${ .trigger.subscription_id }", "transaction_id": "${ .trigger.provider_transaction_id }", "amount": "${ .trigger.amount }", "currency": "${ .trigger.currency }" } }, "then": "end" } }] + } +}) +``` + +### Refresh, then act on the findings — the start / wait / results pattern (any trigger) +Point the `ready` branch at whatever step consumes `${ .read.scan }` (its `findings`, `total_findings`, `risk_level`). +```json +"do": [ + { "scan": { "call": "app_security_scan", "with": { "action": "start" }, "then": "settle" } }, + { "settle": { "wait": "PT15M", "then": "read" } }, + { "read": { "call": "app_security_scan", "with": { "action": "results", "after": "${ .scan.requested_at }" }, "then": "route" } }, + { "route": { "switch": [ + { "ready": { "when": "${ .read.state == \"ready\" }", "then": "end" } }, + { "running": { "when": "${ .read.state == \"running\" }", "then": "settle_again" } }, + { "other": { "then": "end" } } + ] } }, + { "settle_again": { "wait": "PT5M", "then": "read_again" } }, + { "read_again": { "call": "app_security_scan", "with": { "action": "results", "after": "${ .scan.requested_at }" }, "then": "end" } } +] +```