AI GO Custom App — Developer Guide
This document explains how to develop AI GO Custom Apps via API, suitable for automated development by AI Agents and manual integration by human developers.
1. What is a Custom App
A Custom App is a programmable micro-application within the AI GO platform, allowing you to build custom business tools using React + TypeScript.
Core Concepts
- VFS (Virtual File System): Stores all source code as a JSON object
{"file_path": "file_content"}. - esbuild Compiler: Compiles React TSX into browser-executable JS bundles.
- Runtime Sandbox: Safely executes the compiled App in an isolated Shadow DOM environment.
- Server-Side Actions: Python backend scripts executed within a secure sandbox.
Internal vs External
| Feature | Internal | External | Public (Anonymous) |
|---|---|---|---|
| Use Case | Internal management tools | External customer/supplier apps | Product catalogs, venue showcases, public pages |
| Authentication | Platform account login | Independent account system | No login required (anonymous) |
| API Access | Full (via Builder API) | Full (via Builder API) | Read-only pub/ API (see §18) |
| Data Write | ✅ CRUD | ✅ CRUD | ❌ Read-only |
2. Authentication and Connection
Obtaining a JWT Token
All API operations require an account with builder.access permissions. Platform administrators will provide a valid account and password.
POST https://ai-go.app/api/v1/auth/login
Content-Type: application/json
{
"email": "developer@example.com",
"password": "your_password"
}
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "...",
"expires_in": 3600,
"token_type": "bearer"
}
Using JWT
GET /api/v1/builder/apps/{slug}
Authorization: Bearer {access_token}
3. First Connection: Understanding the App Architecture
⚠️ Important: Before making modifications, you must read and understand the current App's VFS structure to avoid using incompatible architectures.
Standard Workflow
1. GET /api/v1/builder/apps/{slug}
→ Fetch vfs_state, vfs_version, access_mode
2. Analyze VFS Structure:
- Read src/App.tsx → Understand routing structure
- Read src/routes.ts → Understand navigation configuration
- Read src/pages/_manifest.json → Page list
3. Verify SDKs:
- src/api.ts → Custom Data CRUD
- src/db.ts → DB Proxy
- src/action.ts → Server-Side Action
Core Rules
- Use React 18 + TypeScript + HashRouter (If the App is a single page, render components directly without a Router).
- React / ReactDOM / lucide-react / react-router-dom are provided by the Runtime and cannot be installed manually.
- Use a global
App.cssfor CSS. CSS Modules or Tailwind are not supported. - The entry point must be
src/main.tsx. - Server-Side Actions must be written in Python and placed in the
actions/directory. - ⚠️ The Runtime executes in a Shadow DOM — CSS variables must use the
:host, :rootdual selector, not just:root(See Chapter 11). - ⚠️ The Shadow DOM container must be set to
overflow-y: auto— otherwise, long content cannot be scrolled (See Chapter 11).
4. VFS File Structure
Standard File Tree
├── package.json # Dependency declarations
├── src/
│ ├── main.tsx # ★ Entry point (must exist)
│ ├── App.tsx # Routing + Layout
│ ├── App.css # Global styles
│ ├── routes.ts # Navigation config
│ ├── api.ts # SDK: Custom Data CRUD
│ ├── db.ts # SDK: DB Proxy
│ ├── action.ts # SDK: Server-Side Action
│ ├── data.json # Custom Table definitions (auto-injected)
│ ├── db.json # Data Reference definitions (auto-injected)
│ ├── pages/
│ │ ├── _manifest.json # Page list
│ │ ├── DashboardPage.tsx # Page component
│ │ └── NotFoundPage.tsx # 404 page
│ └── components/
│ ├── AppLayout.tsx # Main Layout
│ ├── AppSidebar.tsx # Sidebar
│ └── AppHeader.tsx # Header bar
└── actions/
├── manifest.json # Action registration manifest
└── example_action.py # Action implementation
Unmodifiable SDK Files
| File | Description |
|---|---|
src/api.ts | Custom Data CRUD SDK |
src/db.ts | DB Proxy SDK |
src/action.ts | Server Action SDK |
src/data.json | Auto-injected at Runtime |
src/db.json | Auto-injected at Runtime |
5. Code Injection API
API Endpoints Overview
| Operation | HTTP Method | Endpoint |
|---|---|---|
| Get App (inc. VFS) | GET | /api/v1/builder/apps/{slug} |
| Full VFS Overwrite | PUT | /api/v1/builder/apps/{id}/source |
| Partial File Update | PATCH | /api/v1/builder/apps/{id}/source/files |
| Delete Files | DELETE | /api/v1/builder/apps/{id}/source/files |
Partial Update (Recommended)
PATCH /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json
{
"files": {
"src/pages/NewPage.tsx": "import React from 'react';\n\nexport default function NewPage() {\n return <div>New Page</div>;\n}",
"src/App.tsx": "...updated complete content..."
},
"expected_version": 5
}
Delete Files
DELETE /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json
{
"paths": ["src/pages/OldPage.tsx"],
"expected_version": 6
}
Optimistic Locking
All modification endpoints support the expected_version parameter:
- Record
vfs_versionwhen getting the App. - Pass
expected_versionduring modification. - If versions do not match → Returns 409 Conflict.
6. Compilation and Debugging
Compile API
POST /api/v1/compile/compile/{slug}?dev=true
Authorization: Bearer {JWT}
Success Response:
{
"success": true,
"html": "<!DOCTYPE html>...",
"bundle_js": "...",
"css": "..."
}
Failure Response:
{
"success": false,
"error": "✘ [ERROR] Could not resolve \"./pages/MissingPage\"..."
}
Compilation Limits
| Limit | Value |
|---|---|
| Max Files | 200 |
| Max Single File Size | 1 MB |
| Compile Timeout | 30 seconds |
External Modules (Provided by Runtime)
The following modules do not need to be installed; you can import them directly:
react, react-dom, lucide-react, react-router-dom, react-hot-toast
7. Built-in SDKs
Custom Data (src/api.ts)
Manage dynamically created Custom Tables built specifically for the App:
import { listRecords, submitRecord, updateRecord, deleteRecord } from "../api";
const records = await listRecords("my_table");
await submitRecord("my_table", { name: "New Record" });
await updateRecord("my_table", recordId, { name: "Updated" });
await deleteRecord("my_table", recordId);
DB Proxy (src/db.ts)
Manage authorized core system tables:
import { query, queryAdvanced, insert, update, remove } from "../db";
const customers = await query("customers", { limit: 50 });
const result = await queryAdvanced("customers", {
filters: [{ column: "status", op: "eq", value: "active" }],
order_by: [{ column: "name", direction: "asc" }],
});
⚠️ db.update() PATCH Format Warning
The backend Proxy API's PATCH endpoint requires the payload to be wrapped in {"data": {...}}. However, the update() function in the current db.ts SDK directly sends the fields object, which triggers a "No valid field data" error.
Temporary Workaround: Where data updates are required, use a direct fetch call instead:
// ❌ Currently db.update() sends {"state": "sent"} → Backend returns 400
await db.update("sale_orders", orderId, { state: "sent" });
// ✅ Correct approach: Use direct fetch and wrap with {"data": {...}}
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const appId = (window as any).__APP_ID__ || '';
const token = (window as any).__APP_TOKEN__ || '';
const resp = await fetch(`${apiBase}/proxy/${appId}/sale_orders/${orderId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
body: JSON.stringify({ data: { state: "sent" } }),
});
Note:
db.insert()suffers from the same formatting inconsistency. If aninsert()call fails, apply the same{"data": {...}}wrapper pattern. This issue is scheduled to be fixed in the next SDK version.
Handling Approval Workflow Intercepts
When administrators configure an "Approval Workflow" for specific tables (like sale_orders) in the AI GO backend, your db.insert, db.update, or db.remove calls may be intercepted by the approval engine:
- Insert (Insert-then-flag): The record is written to the database first (to obtain the relationship ID), but business logic is not formally triggered. It goes directly into a Pending state awaiting approval.
- Update / Delete (Pre-guard): The record is not actually updated or deleted. The system temporarily stores your payload in an approval request until it is officially approved and executed.
Intercept Response Format:
When your operation requires approval, the API returns a payload containing approval_status: "pending". Frontend developers should intercept this state and provide corresponding feedback to the user, rather than simply displaying "Operation Successful".
// Example: Handling an approval intercept on insert
const result = await db.insert("sale_orders", { data: { amount_total: 5000 } });
if (result.approval_status === "pending") {
// result.approval_message: "This operation requires approval (2 levels). Request created."
toast.success(result.approval_message || "Approval request submitted");
} else {
toast.success("Order created successfully");
}
Approval State Callbacks
To prevent Custom App developers from having to write Python backend code just to handle approval states, AI GO provides Generic State Callbacks.
For db.insert and db.update operations, you can configure the following three fields when creating an ApprovalWorkflow. The core engine will automatically update the record's status when a manager approves or rejects:
approved_state_field: The status field name to update (e.g.,"state","status","doc_status").approved_state_value: The value to write upon approval (e.g.,"approved","validate","done").rejected_state_value: The value to write upon rejection (e.g.,"rejected","draft").
How it works:
- When the frontend submits an
insertoperation, the record is directly stored in the database with adraftorpendingstatus, and a pending approval request is generated. - When the final manager clicks Approve, the core engine automatically executes:
UPDATE "your_table" SET "{approved_state_field}" = '{approved_state_value}' WHERE id = ?. - If a manager clicks Reject, it automatically executes:
UPDATE "your_table" SET "{approved_state_field}" = '{rejected_state_value}' WHERE id = ?.
With this mechanism, your Custom App only needs to build the frontend interface and query conditions (e.g., fetching only documents where state === 'approved') to achieve a complete end-to-end no-code approval loop!
Server Action (src/action.ts)
The return value of Actions is automatically destructured by the SDK. data directly captures the JSON payload returned from your Python code.
import { runAction, downloadFile } from "../action";
const { data, file } = await runAction("my_action", { key: "value" });
console.log("Action Result:", data);
if (file) downloadFile(file);
8. Server-Side Actions
Code Format
def execute(ctx):
"""The execute(ctx) function must be defined"""
data = ctx.params.get("key", "default")
customers = ctx.db.query("customers", limit=10)
ctx.response.json({"result": customers})
The ctx Object
| Method | Description |
|---|---|
ctx.params | Parameters passed from the frontend |
ctx.db.query(table, **kwargs) | Query data. Supports advanced params like order_by, search, limit. Example:ctx.db.query("clients", limit=50, order_by=[{"column": "id", "direction": "desc"}]) |
ctx.db.insert(table, data) | Insert record |
ctx.http.call(service, endpoint) | Call external API |
ctx.crypto.hash(alg, data) | Hash calculation |
ctx.secrets.get(key) | Get secret key |
ctx.response.json(data) | JSON response |
ctx.csv.export(rows) | Export CSV |
Security Limits
- Only whitelisted modules are allowed (json, math, re, datetime, httpx, etc.)
- Dangerous operations like exec/eval/open are forbidden
- Execution timeout is 30 seconds; memory limit is 256 MB
9. Verification and Publishing
Standard Development Loop
1. PATCH to modify files
2. POST to compile (dev=true)
3. Compilation fails → Fix → Go back to 1
4. Compilation succeeds → Preview to verify
5. POST to publish
Publish API
POST /api/v1/builder/apps/{app_id}/publish
Authorization: Bearer {JWT}
Content-Type: application/json
{ "published_assets": {} }
10. FAQ
| Issue | Solution |
|---|---|
| White Screen | Verify that React is mounted correctly in src/main.tsx |
| Routing not working | Use HashRouter, do not use BrowserRouter |
| Page cannot scroll | Shadow DOM container must set height: 100vh; overflow-y: auto (See Chapter 11) |
| CSS not applied | Add import "./App.css" in main.tsx |
| CSS variables entirely missing (after deployment) | Using :root to define variables cannot penetrate the Shadow DOM. Use :host, :root instead (See Chapter 11) |
db.update() returns "No valid field data" | The SDK's update() does not wrap the payload with {"data": {...}} (See Chapter 7 DB Proxy Warning) |
db.ts calls return 500 | This is a platform backend issue, not a frontend bug. Verify: 1) Data Reference is created and published 2) Table name is correct 3) Report to platform admin to check backend logs |
db.ts / api.ts return 401 | Token may be expired or SDK variables are improperly injected. Verify SDKs are not manually modified and Runtime started correctly |
| 409 Conflict | VFS modified concurrently. GET the latest, merge, and retry |
| 423 Locked | Pending publish request. Wait for approval or cancel it |
| Action timeout | Optimize logic to complete within 30 seconds |
| Forbidden module import | Use whitelisted modules or ctx.http.call() |
| pub/ API returns 403 | Verify allow_anonymous_access=true and the table has is_public_readable=true (see §18) |
| pub/ API returns 429 | Exceeded anonymous Rate Limit (120/min per IP), try again later |
11. Shadow DOM and CSS Style Guidelines
⚠️ Important: Custom Apps run inside the AI GO Runtime encapsulated within a Shadow DOM, which is fundamentally different from a standalone HTML page. Failing to follow these guidelines will cause the "works locally but all styles disappear after deployment" issue.
Root Cause
The CSS :root selector matches the document tree's root element <html>. When an App runs inside a Shadow DOM, :root cannot penetrate the Shadow boundary. All CSS variables defined via :root { --color: blue; } are completely inaccessible from inside the App.
Main Site HTML (<html> = :root effective range)
└── <custom-app-runtime> ← Web Component
└── #shadow-root (closed) ← Shadow DOM boundary
└── <div id="root"> ← :root cannot reach this area
Mandatory Rules
All CSS variables must use the :host, :root dual selector:
/* ✅ Correct: Works in both Shadow DOM and standalone pages */
:host, :root {
--primary: #2563eb;
--background: #fafbfc;
}
/* ❌ Incorrect: All variables lost after deployment */
:root {
--primary: #2563eb;
}
The same applies to HTML element resets:
/* ✅ Correct */
html, :host {
line-height: 1.5;
font-family: 'Inter', system-ui, sans-serif;
}
/* ❌ Incorrect */
html {
line-height: 1.5;
}
Selector Comparison Table
| Selector | Standalone HTML Page | Shadow DOM (AI GO Runtime) |
|---|---|---|
:root | ✅ | ❌ Cannot penetrate |
:host | ❌ Meaningless | ✅ Matches Shadow Host |
:host, :root | ✅ fallback | ✅ Matches |
Self-Check Checklist
- Global search for
:root {(without:host) — Change to:host, :root { - Global search for
html {(without:host) — Change tohtml, :host { - Ensure Dark Mode
@mediablocks also use:host, :root
JavaScript API Limitations
Certain native browser APIs are silently blocked (no errors thrown, no visual display) inside a Shadow DOM, leading to "preview works, deployment has no response":
| API | Shadow DOM Behavior | Alternative |
|---|---|---|
confirm() | Silently returns false | React useState two-step confirmation |
alert() | Does not display | react-hot-toast or custom Toast |
prompt() | Returns null | React custom input modal |
// ✅ Correct: React state confirmation
const [showConfirm, setShowConfirm] = useState(false);
// ❌ Incorrect: confirm() always returns false in Runtime
if (!confirm("Are you sure?")) return;
APIs that work normally: localStorage, fetch, window.location.reload().
Container Scrolling Constraints
The root container of a Shadow DOM does not scroll by default. When the App's content exceeds the viewport height, users cannot scroll down.
You must set explicit height and overflow behaviors on the outermost Layout component:
// ✅ Correct: Explicitly set height and scroll behavior
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{
height: "100vh",
overflowY: "auto",
backgroundColor: "var(--color-gray-50)",
}}>
{children}
</div>
);
}
// ❌ Incorrect: minHeight does not trigger overflow
<div style={{ minHeight: "100vh" }}>
Single-Page App Routing Simplification
If your Custom App only has a single main page (e.g., an order board, a dashboard), you do not need React Router. Render the main component directly in App.tsx to prevent blank screens caused by missing Router contexts:
// ✅ Single-Page App — Direct rendering, no Router
import OrderBoardPage from "./pages/OrderBoardPage";
export default function App() {
return (
<AppLayout>
<Toaster position="top-center" />
<OrderBoardPage />
</AppLayout>
);
}
// ❌ Single-Page App using BrowserRouter — results in white screen in Shadow DOM
import { BrowserRouter, Routes, Route } from "react-router-dom";
// BrowserRouter cannot control Runtime URLs, routes will never match
When Router is Needed: You only need
HashRouterwhen the App has multiple pages (e.g., toggling via a Sidebar navigation).
12. VFS Injection Script Development Guidelines
⚠️ When using Python scripts to construct React JSX source code directly, it is highly likely to introduce syntax errors via string operations, causing esbuild compilation failures.
String Operation Risks
# ❌ Dangerous: Modifying JSX with str.replace()
text = text.replace(
"return (\n <main>",
"return (\n return (\n <main>" # Accidental double return
)
# esbuild error: Unexpected "return"
Recommended Approach
Define every VFS file using complete raw strings without string concatenation or replacement:
# ✅ Correct: Complete definition, no string operations
files["src/pages/CartPage.tsx"] = r'''import React from "react";
export default function CartPage() {
return (
<main className="container">
<h1>Shopping Cart</h1>
</main>
);
}
'''
Compilation Defense
After calling the Compile API, deployment scripts must check the success field:
result = r.json()
if not result.get("success"):
print(f"❌ Compilation Failed:\n{result.get('error')}")
sys.exit(1) # Never allow publishing with compilation errors
VFS Version Locking
When fetching App details, you must use GET /builder/apps/{id} (single object endpoint) to get the exact vfs_version, rather than using the list endpoint.
13. Custom Table Structure Management API
💡 Use Case: When external AI Agents need to dynamically create, modify, and delete custom data tables (Custom Tables) for a Custom App via API, rather than merely manipulating records.
Overview
Custom Tables can be created not only manually via the Builder UI but also programmatically via API. Authentication works the same as other Builder APIs: Platform account JWT + builder.access permission.
This function mirrors the API creation for References (
/refs/apps/{id}), but operates on Custom Tables (/data/objects).
API Endpoints Overview
| Operation | HTTP Method | Endpoint | Description |
|---|---|---|---|
| List Tables | GET | /api/v1/data/objects?app_id={app_id} | Includes field definitions |
| Create Table | POST | /api/v1/data/objects | Single table creation |
| Create Table + Fields | POST | /api/v1/data/objects/batch | Recommended: Create table + define fields at once |
| Delete Table | DELETE | /api/v1/data/objects/{obj_id} | Cascading deletion of fields + records |
| Add Field | POST | /api/v1/data/objects/{obj_id}/fields | — |
| List Fields | GET | /api/v1/data/objects/{obj_id}/fields | — |
| Modify Field | PATCH | /api/v1/data/fields/{field_id} | name / field_type / is_required / sequence |
| Delete Field | DELETE | /api/v1/data/fields/{field_id} | — |
Batch Table + Fields (Recommended)
Complete table creation and all field definitions in one API call, reducing AI Agent round-trips:
POST /api/v1/data/objects/batch
Authorization: Bearer {JWT}
Content-Type: application/json
{
"app_id": "your-app-uuid",
"name": "Order Management",
"api_slug": "orders",
"fields": [
{ "name": "Order No", "field_key": "order_no", "field_type": "text", "is_required": true, "sequence": 1 },
{ "name": "Amount", "field_key": "amount", "field_type": "number", "is_required": false, "sequence": 2 },
{ "name": "Date", "field_key": "order_date", "field_type": "date", "is_required": false, "sequence": 3 }
]
}
Response (Includes complete field definitions):
{
"id": "uuid",
"tenant_id": "uuid",
"app_id": "uuid",
"name": "Order Management",
"api_slug": "orders",
"fields": [
{ "id": "uuid", "object_id": "uuid", "name": "Order No", "field_key": "order_no", "field_type": "text", "is_required": true, "sequence": 1 },
{ "id": "uuid", "object_id": "uuid", "name": "Amount", "field_key": "amount", "field_type": "number", "is_required": false, "sequence": 2 },
{ "id": "uuid", "object_id": "uuid", "name": "Date", "field_key": "order_date", "field_type": "date", "is_required": false, "sequence": 3 }
]
}
fieldsare optional: Pass an empty array or omit to create an empty table.
Single Table Creation
POST /api/v1/data/objects
Authorization: Bearer {JWT}
Content-Type: application/json
{
"app_id": "your-app-uuid",
"name": "Customer Management",
"api_slug": "customers"
}
Add Field
POST /api/v1/data/objects/{obj_id}/fields
Authorization: Bearer {JWT}
Content-Type: application/json
{
"name": "Status",
"field_key": "status",
"field_type": "text",
"is_required": false,
"sequence": 10
}
Modify Field
PATCH /api/v1/data/fields/{field_id}
Authorization: Bearer {JWT}
Content-Type: application/json
{
"name": "New Name",
"field_type": "number"
}
Delete Table
DELETE /api/v1/data/objects/{obj_id}
Authorization: Bearer {JWT}
⚠️ Cascade Deletion: Deleting a table simultaneously deletes all field definitions and recorded data.
Field Types
| field_type | Description | Example Values |
|---|---|---|
text | Text | "Hello" |
number | Number | 42, 3.14 |
date | Date | "2026-04-23" |
relation | Relation | Record IDs from other tables |
api_slug Naming Rules
- Only allows lowercase English letters, numbers, and underscores.
- Regex:
^[a-z0-9]([a-z0-9_]*[a-z0-9])?$ - Examples:
orders,customer_info,product_v2 - ❌ Invalid:
Orders(uppercase),my-table(hyphen),_start(starts with underscore)
Quota Limits
| Limit | Value |
|---|---|
| Max tables per App | 20 |
| Max fields per table | 50 |
Returns 409 Conflict when exceeding limits.
Full AI Agent Workflow Example
import httpx
BASE = "https://ai-go.app/api/v1"
# Step 1: Login to get Token
resp = httpx.post(f"{BASE}/auth/login", json={
"email": "developer@example.com",
"password": "your_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Step 2: Batch Table + Fields
resp = httpx.post(f"{BASE}/data/objects/batch", headers=headers, json={
"app_id": "your-app-uuid",
"name": "Order Management",
"api_slug": "orders",
"fields": [
{"name": "Client Name", "field_key": "customer", "field_type": "text", "is_required": True, "sequence": 1},
{"name": "Amount", "field_key": "amount", "field_type": "number", "sequence": 2},
]
})
obj_id = resp.json()["id"]
# Step 3: Write Record
resp = httpx.post(f"{BASE}/data/objects/{obj_id}/records", headers=headers, json={
"data": {"customer": "TSMC", "amount": 500000}
})
# Step 4: Query Records
resp = httpx.get(f"{BASE}/data/objects/{obj_id}/records", headers=headers)
print(resp.json())
14. Shared Data Domain Separation Strategy
💡 Use Case: When multiple Custom Apps share identical SaaS standard tables (like
product_templates,sale_orders,customers), how to prevent data contamination across apps.
When building Custom Apps in a microservice architecture, we often have multiple Apps share core tables to facilitate creating unified revenue or customer reports in the future. However, different Apps' frontends should only see their own data. To achieve this, a Data Domain isolation strategy must be introduced.
Core Strategy: app_domain Tag
Utilize a JSON field within the table (usually custom_data) to inject an app_domain property into all relevant records. Each Custom App holds an exclusive Domain identifier (e.g., F&B = "food", space rental = "space").
Implementation Steps
-
Tag Injection (Insert): When executing
insertvia SDKdb.tsor Server-Side Actions, force thecustom_data.app_domaininjection.await insert("sale_orders", { data: { name: `ORDER-${Date.now()}`, amount_total: 1000, custom_data: { app_domain: "space", // ← Declare data ownership booking_date: "2024-05-01" } } }); -
Forced Filtering (Query): For all
queryactions, whether lists or relational queries, explicit JSON field filters must be added.const spaces = await query("product_templates", { filters: [{ column: "custom_data", op: "ilike", value: "%space%" // ← Filter data belonging only to app_domain="space" }], limit: 100 });Note: Using
ilikeor advanced JSONB operators is a common workaround; native JSON structure filtering will be supported by the platform in the future. -
Whitelist Isolation (AppDataReference): When establishing an App's DB Proxy authorization (
app_data_referencestable), this cannot restrict Row-Level access. Therefore, protection at the frontend code level is mandatory. Only a properly implemented frontend filter, paired with correct AppDataReference field whitelists, can achieve comprehensive data isolation.
Shared Table vs. Dedicated Table Dilemma
- Use Dedicated Tables (Custom Data): Ideal for highly customized fields unique to that business user without intersection with core finance/product functions (e.g., customer satisfaction surveys, shift schedules).
- Use Shared Standard Tables +
app_domain: Ideal for data that can share underlying infrastructures, like products (product_templates), orders (sale_orders), and customers (customers). This facilitates building unified cross-departmental financial reports on the admin backend later.
15. File Upload and Storage API
💡 Use Case: When Custom Apps require users to upload images, documents, or other files, use the platform-provided Storage API for unified management.
Overview
Custom Apps can perform file uploads, downloads, listings, and deletions via /api/v1/ext/storage/* endpoints. All files are automatically stored under isolated paths tied to the tenant and App to ensure data security.
Authentication
All Storage APIs require the Custom App Token (identical to the ext/proxy auth mechanism). The token can be accessed at Runtime via window.__APP_TOKEN__.
API Endpoints
| Operation | HTTP Method | Endpoint |
|---|---|---|
| Upload File | POST | /api/v1/ext/storage/upload |
| Get File URL | GET | /api/v1/ext/storage/url?path={path} |
| Delete File | DELETE | /api/v1/ext/storage/file?path={path} |
| List Files | GET | /api/v1/ext/storage/list?folder={folder} |
Upload File
POST /api/v1/ext/storage/upload
Authorization: Bearer {custom_app_token}
Content-Type: multipart/form-data
file: (binary)
folder: "receipts" # Optional, subfolder name
Response:
{
"path": "tenant-id/app-id/receipts/invoice.pdf",
"bucket": "files",
"size": 102400,
"mime_type": "application/pdf"
}
Get Signed URL
GET /api/v1/ext/storage/url?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
Response:
{
"url": "https://xxx.supabase.co/storage/v1/object/sign/files/...",
"expires_in": 3600
}
List Files
GET /api/v1/ext/storage/list?folder=receipts&limit=50&offset=0
Authorization: Bearer {custom_app_token}
Response:
{
"files": [
{ "name": "invoice.pdf", "size": 102400, "updated_at": "2026-04-07T12:00:00Z" }
],
"count": 1
}
Delete File
DELETE /api/v1/ext/storage/file?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
Limits and Security
| Limit | Value |
|---|---|
| Max File Size | 100 MB |
| Storage Bucket | files (shared, path isolated) |
| Path Format | {tenant_id}/{app_id}/{folder}/{filename} |
| Cross-App Access | ❌ 403 Forbidden |
Usage in Frontend
// Upload file
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const token = (window as any).__APP_TOKEN__ || '';
async function uploadFile(file: File, folder?: string) {
const formData = new FormData();
formData.append('file', file);
if (folder) formData.append('folder', folder);
const resp = await fetch(`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
return resp.json();
}
// Get Signed URL
async function getFileUrl(path: string) {
const resp = await fetch(
`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/url?path=${encodeURIComponent(path)}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const data = await resp.json();
return data.url;
}
Note: Uploaded files count toward the tenant's storage usage metrics. Administrators can view this via "Usage Management > Storage" on the Dashboard.
16. Best Practices for Development and Deployment
To ensure maintainability and cross-environment synchronization stability for Custom Apps, please follow these safeguards and best practices during development and Continuous Integration (CI/CD):
15.1 VFS Synchronization and Integrity Validation (Environment Sync)
When building Scaffolding Scripts to push local source code to cloud Custom App endpoints, a disconnect often occurs where "local files don't catch up with the cloud":
- Correctly isolate environment variables: If you use APIs to write quick deployment scripts, ensure your CLI tools strictly validate or separate
--env localand--env cloud. Pushing code to the wrong environment keys will trigger a500 - Failed to load template from Storageoutage error on the cloud Dashboard (because the cloud sees a version bump but no actual files). - Atomic operations: It is highly recommended to use
PATCH /api/v1/builder/apps/{id}/source/filesto update all dependent VFS files in a single pass, and append aGETcheck before the command finishes to confirm whether theVFS File Countmatches.
15.2 Defensive Coding and Disabling Placeholders (Prevent Lazy Code Replacement)
When maintaining massive React components or complex logic, human developers or AI Agent coding assistants often use lazy placeholders like # ... or // ... Original Code. In traditional projects, this might be harmless, but in the VFS Dynamic Compilation Architecture, it is fatal:
- Destroys Compiler Context: As the esbuild compiler processes your TSX in the cloud, any omitted or incomplete code snippets will immediately cause AST parsing failures, subsequently blocking the rendering of the entire App.
- Strict Guidelines:
- No matter how minor the update is, every overwrite of a single VFS file must provide 100% of the complete source code.
- Leaving
// Code omitted hereor// ...in the source code is strictly prohibited. - Leverage TypeScript for strict type definitions. Once an implicit typing error occurs, debugging costs in the Runtime sandbox will be significantly higher than local development.
17. Internal App Independent Login and Member Invitation
Internal Custom Apps provide an independent login/registration page, allowing organizational members to access the application directly without navigating through the main site Dashboard. Administrators can also issue invitation links, letting new members complete registration and enter the application in one step.
17.1 Independent Login Page URL
Each Internal App has an independent login gateway:
https://ai-go.app/app-login/{slug}
{slug}: The App's slug (queryable via Builder or API).- This page loads without requiring login, automatically displaying the App name and Tenant Logo.
- If the user already has a session, it automatically validates permissions and redirects to the Runtime.
17.2 Login Flow
User opens /app-login/{slug}
↓
Page loads public App info (Name, Logo)
↓
User enters credentials → Login
↓
Automatically calls check-access API to verify permissions
↓
├── Access granted → Redirect to /runtime/{slug}
└── Access denied → Displays "Cannot access this application"
17.3 Relevant API Endpoints
Public App Info (No Auth Required)
GET /api/v1/builder/apps/public/{slug}
Response:
{
"name": "Kitchen Order Management",
"slug": "c7c7a37d2ff0",
"subdomain": null,
"tenant_logo_url": "https://..."
}
Returns
404if the App does not exist or is unpublished.
Permission Check (Auth Required)
GET /api/v1/builder/apps/check-access/{slug}
Authorization: Bearer {JWT}
Response:
{
"has_access": true,
"app_name": "Kitchen Order Management",
"reason": null
}
Access Check Logic:
| Check Item | Rejection Response |
|---|---|
| App exists and is published | has_access: false, reason: "App does not exist or is unpublished" |
| User belongs to same org | has_access: false, reason: "Your account does not belong to the organization hosting this application" |
| User role in allowed list | has_access: false, reason: "Your role is not on the allowed list for this application" |
17.4 Invite Members + Direct App Entry
Administrators can generate invite links enabling new members to complete registration right on the App login page without entering the main site:
POST /api/v1/invitations
Authorization: Bearer {JWT}
Content-Type: application/json
{
"email": "new-member@company.com",
"name": "New Member",
"role_ids": ["member"],
"redirect_url": "/app-login/{slug}"
}
Response:
{
"token": "U1HjLnpLHgAM8hZE...",
"chat_invite_link": "https://ai-go.app/app-login/{slug}?token=U1HjLnpLHgAM8hZE..."
}
Crucial: When
redirect_urlstarts with/app-login/, the system automatically generates an App-specific invite link, allowing the invitee to finish registration directly on the App's login page.
17.5 Invitee Registration Experience
When an invitee clicks the invitation link (containing ?token=xxx), the login page will automatically:
- Switch to Registration Mode — Header displays "Register {App Name}".
- Lock Email Field — Pre-fills the invited Email, making it uneditable.
- Display Inviter Info — "You have been invited by '{Inviter}' to join '{Organization}'."
- Invitee only needs to fill in Name and Password to complete registration.
- Upon success, auto-login occurs, redirecting straight to the App Runtime.
17.6 Error Handling
| Scenario | Page Display |
|---|---|
| slug does not exist | "Application not found" error page |
| Invitation token invalid or expired | "Invitation link invalid" + "Go to Login" button |
| Incorrect credentials | "Incorrect username or password" displayed in form (No redirect) |
| Login successful but no access | "Cannot access this application" + Recommend contacting admin |
17.7 Forgot Password
The login page incorporates a native "Forgot Password" feature. Clicking it opens a Dialog (without navigating away), which sends a password reset email after entering an Email address. After resetting, users can log in directly on the original page without losing the App slug or invitation token.
17.8 Logout
Logout for Internal Apps is a pure frontend operation that does not require calling a backend endpoint. Custom App developers can add a logout button within their application UI and call the Supabase SDK:
import { supabase } from './api'; // Built-in Supabase instance in Runtime
// Logout and redirect back to App login page
async function handleLogout() {
await supabase.auth.signOut();
window.location.href = `/app-login/${APP_SLUG}`;
}
Explanation: Internal Apps share the main site's Supabase Auth.
signOut()clears the session in localStorage and revokes the refresh token. Since JWT is stateless, the backend requires no extra processing. Following logout, users can log back in at/app-login/{slug}.
Note: This logout action will simultaneously log the user out of the main site Dashboard session. If a "logout only the App but preserve main site" behavior is necessary, you can manually clear App-specific localStorage keys instead, though this distinction is generally unnecessary.
17.9 AI Agent Integration Example
Agents can automate the invitation flow via API, allowing new members to quickly join and utilize the App:
import httpx
BASE = "https://ai-go.app/api/v1"
# 1. Admin Login
resp = httpx.post(f"{BASE}/auth/login", json={
"email": "admin@company.com",
"password": "admin_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Create Invitation (Link directs straight to App login page)
resp = httpx.post(f"{BASE}/invitations", headers=headers, json={
"email": "new-user@company.com",
"name": "New Colleague",
"role_ids": ["member"],
"redirect_url": "/app-login/c7c7a37d2ff0"
})
invite_link = resp.json()["chat_invite_link"]
print(f"Please send this link to the new member: {invite_link}")
# → https://ai-go.app/app-login/c7c7a37d2ff0?token=xxx
18. Public Anonymous Access
💡 Use Case: When a Custom App needs to provide publicly accessible pages that do not require login, such as product catalogs, venue introductions, pricing plan displays, etc. This mode allows visitors to anonymously browse published App content, while still supporting a switch to full functionality after logging in.
18.1 Overview
Public Anonymous Access is the third access mode for Custom Apps, filling the public browsing need beyond Internal (internal apps) and External (external apps):
- Internal: Restricted to organization members after login
- External: External-facing apps with an independent account system
- Public (Anonymous Access): Anyone can browse designated public data without logging in
In anonymous mode, visitors can only read data marked as public and cannot perform create, update, or delete operations. If write functionality is needed, visitors can log in via Custom App Auth to automatically switch to authenticated mode.
18.2 Activation Requirements
Enabling anonymous public browsing requires configuration at three levels simultaneously:
Level 1: App Settings
| Field | Required Value | Description |
|---|---|---|
status | "published" | App must be published |
allow_anonymous_access | true | Enable anonymous access |
access_mode | "external" or "self_built" | External modes only |
Configure via the Builder API:
PATCH /api/v1/builder/apps/{app_id}
Authorization: Bearer {JWT}
Content-Type: application/json
{
"allow_anonymous_access": true
}
This can also be toggled in Builder UI → Publish Panel → "Allow Anonymous Access" switch.
Level 2: Custom Data Tables
Each Custom Data table individually controls whether it is accessible via the anonymous API:
PATCH /api/v1/data/objects/{obj_id}
Authorization: Bearer {JWT}
Content-Type: application/json
{
"is_public_readable": true
}
In Builder UI → Data Management → the "Public Readable" toggle on each table.
Level 3: SaaS Reference Tables (AppDataReference)
If the App references system SaaS tables (e.g., customers, products), the Reference must also be configured:
PATCH /api/v1/refs/{ref_id}
Authorization: Bearer {JWT}
Content-Type: application/json
{
"is_public_readable": true
}
In Builder UI → Data References → the "Public Readable" toggle on each reference.
18.3 Public API Endpoints
The following endpoints do not require any authentication Token and identify the target application via the App's slug.
Custom Data Anonymous Read-Only
| Endpoint | Method | Description |
|---|---|---|
/api/v1/pub/data/{slug}/objects | GET | List public tables (including field definitions) |
/api/v1/pub/data/{slug}/objects/{obj}/records | GET | List records of a specified table |
Example: List Public Tables
GET /api/v1/pub/data/aeb47f756cef/objects
Response:
[
{
"id": "uuid",
"name": "Venues",
"api_slug": "venues",
"is_public_readable": true,
"fields": [
{ "id": "uuid", "name": "Name", "field_key": "name", "field_type": "text" },
{ "id": "uuid", "name": "Address", "field_key": "address", "field_type": "text" }
]
}
]
Example: Query Records
GET /api/v1/pub/data/aeb47f756cef/objects/venues/records?limit=10&offset=0
{obj}can be anapi_slug(e.g.,venues) or a UUID.
SaaS Reference Anonymous Read-Only
| Endpoint | Method | Description |
|---|---|---|
/api/v1/pub/proxy/{slug}/{table} | GET | Simple query |
/api/v1/pub/proxy/{slug}/{table}/query | POST | Advanced query (filters / search / sort) |
Example: Advanced Query
POST /api/v1/pub/proxy/aeb47f756cef/products/query
Content-Type: application/json
{
"filters": [
{ "column": "status", "op": "eq", "value": "active" }
],
"order_by": [{ "column": "name", "direction": "asc" }],
"limit": 20,
"offset": 0
}
⚠️ The
limitcap for pub/proxy is 100. Values exceeding this are automatically capped to 100.
18.4 Frontend SDK Integration
The Custom App SDK (src/api.ts) has built-in automatic anonymous mode switching logic. When the Runtime detects that the user is not logged in, the SDK automatically uses the pub/ endpoints.
Automatic Switching Logic
// Internal logic in api.ts (auto-generated by Runtime, no manual modification needed)
export async function listRecords(slug: string): Promise<any[]> {
const token = (window as any).__APP_TOKEN__ || '';
if (!token) {
// Not logged in → Use public API (no Token required)
const res = await fetch(
`${API_BASE}/pub/data/${APP_SLUG}/objects/${slug}/records?limit=100`
);
return res.json();
}
// Logged in → Use standard authenticated API
const res = await fetch(`${API_BASE}/data/objects/${slug}/records`, {
headers: { Authorization: `Bearer ${token}` }
});
return res.json();
}
Runtime Global Variables
The Runtime injects the following global variables when the App starts:
| Variable | Description | Anonymous Mode Value | After Login Value |
|---|---|---|---|
window.__APP_TOKEN__ | JWT Access Token | "" (empty string) | "eyJ..." |
window.__APP_SLUG__ | App's slug | Has value | Has value |
window.__APP_ID__ | App UUID | Has value | Has value |
window.__API_BASE__ | API base URL | Has value | Has value |
window.__IS_AUTHENTICATED__ | Whether authenticated | false | true |
Detecting Login State in Pages
import React from "react";
export default function VenueListPage() {
const isLoggedIn = !!(window as any).__APP_TOKEN__;
return (
<main>
<h1>Venue List</h1>
{/* All visitors can see venue data */}
<VenueList />
{/* Only show booking button for logged-in users */}
{!isLoggedIn && (
<p>
Want to book a venue?
<a href="#/login">Please log in first</a>
</p>
)}
</main>
);
}
18.5 Hybrid Mode: Anonymous + Login Switching
Custom Apps support a seamless "anonymous browsing → login → full functionality" transition:
Visitor opens App page
↓
Runtime detects: No Token
↓
Injects __APP_TOKEN__ = "", __IS_AUTHENTICATED__ = false
↓
SDK automatically uses pub/ API (read-only)
↓
Visitor clicks "Login" → Custom App Auth login page
↓
Login successful → Auth SDK updates window.__APP_TOKEN__
↓
SDK automatically switches to authenticated API (full CRUD)
Auth SDK Auto-Injection
For Apps with access_mode = "external", the Runtime automatically injects the Auth SDK, providing the following global methods:
// These methods are automatically available on the window.__auth__ object
window.__auth__.login(email, password) // Login
window.__auth__.register(email, password, displayName) // Register
window.__auth__.logout() // Logout (clears Token)
window.__auth__.getToken() // Get current Token
Login Page Example
import React, { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleLogin = async () => {
try {
await (window as any).__auth__.login(email, password);
// Login successful → Token auto-updated → Redirect to home
window.location.hash = "#/";
window.location.reload();
} catch (err: any) {
setError(err.message || "Login failed");
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p className="error">{error}</p>}
<button type="submit">Login</button>
</form>
);
}
18.6 Rate Limiting
To protect anonymous endpoints from abuse, the pub/ API has dedicated rate limits:
| Endpoint Scope | Limit | Basis |
|---|---|---|
/api/v1/pub/data/* + /api/v1/pub/proxy/* | 120 requests / minute | per IP |
/api/v1/custom-app-auth/* (POST) | 10 requests / minute | per IP |
Authenticated /api/v1/data/* + /api/v1/proxy/* | 600 requests / minute | per user |
The anonymous Rate Limit and authenticated Rate Limit are independent of each other. Logged-in users enjoy a 600/min quota.
Response Headers:
Every pub/ API response includes:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
When the limit is exceeded:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"detail": "Too many requests, please try again later"
}
18.7 Security Mechanisms
| Protection | Mechanism |
|---|---|
| Column Whitelist | filters, search_columns, and select_columns are all validated against the allowed_columns whitelist; querying unauthorized columns is forbidden |
| Limit Cap | pub/proxy limit max is 100; pub/data is validated by FastAPI Query |
| Read-Only | pub/ endpoints only allow GET and POST query; INSERT / UPDATE / DELETE are not permitted |
| SQL Injection | All parameters are bound via SQLAlchemy; no SQL string concatenation |
| App Validation | Every request validates that the slug corresponds to an App that exists, is published, and allows anonymous access |
| Usage Logging | Every pub/ API call is logged to usage_events; administrators can monitor usage |
18.8 Builder API Automation Setup
AI Agents or external scripts can enable Public mode in one go via the following workflow:
import httpx
BASE = "https://ai-go.app/api/v1"
# 1. Admin Login
resp = httpx.post(f"{BASE}/auth/login", json={
"email": "admin@company.com",
"password": "admin_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
APP_ID = "your-app-uuid"
# 2. Enable Anonymous Access
resp = httpx.patch(f"{BASE}/builder/apps/{APP_ID}", headers=headers, json={
"allow_anonymous_access": True
})
print(f"Anonymous access: {resp.json().get('allow_anonymous_access')}")
# 3. Set Custom Data Tables as Publicly Readable
resp = httpx.get(f"{BASE}/data/objects?app_id={APP_ID}", headers=headers)
for obj in resp.json():
if obj["api_slug"] in ("venues", "products", "prices"):
httpx.patch(f"{BASE}/data/objects/{obj['id']}", headers=headers, json={
"is_public_readable": True
})
print(f" ✓ {obj['api_slug']} → public")
# 4. Set References as Publicly Readable
resp = httpx.get(f"{BASE}/refs/apps/{APP_ID}", headers=headers)
for ref in resp.json():
if ref["table_name"] in ("crm_tags",):
httpx.patch(f"{BASE}/refs/{ref['id']}", headers=headers, json={
"is_public_readable": True
})
print(f" ✓ ref {ref['table_name']} → public")
# 5. Publish
resp = httpx.post(f"{BASE}/builder/apps/{APP_ID}/publish", headers=headers, json={
"published_assets": {}
})
print(f"Publish result: {resp.status_code}")
# 6. Verify Anonymous Access
slug = "your-app-slug"
resp = httpx.get(f"{BASE}/pub/data/{slug}/objects")
print(f"Anonymous access test: {resp.status_code} → {len(resp.json())} public table(s)")
18.9 FAQ
| Issue | Solution |
|---|---|
| pub/ API returns 404 | Verify that the App is published (status = published) and the slug is correct |
| pub/ API returns 403 | Verify allow_anonymous_access = true and the corresponding table has is_public_readable = true |
| pub/data doesn't show certain tables | The table may have is_public_readable = false, or the app_id doesn't match (only tables belonging to that App or shared by the tenant are shown) |
| Data visible on page but disappears after login | After login, the SDK uses the authenticated API; verify that the authenticated Data Reference is also properly configured |
| Cannot submit forms in anonymous mode | Expected behavior — pub/ API only allows reads; form submission requires logging in |
| Rate Limit 429 error | Anonymous mode limits to 120 requests/min per IP; consider adding request deduplication and caching on the frontend |
__IS_AUTHENTICATED__ is always false | Verify that the Auth SDK is correctly injected. For External Apps, this value being false on initial anonymous load is normal |
19. External App Independent Authentication System
💡 Use Case: External mode Custom Apps use an account system independent from the main site, allowing external users (customers, suppliers, visitors) to register, log in, and authenticate via Email + password. This mechanism is completely independent of the Internal App system described in §17 (Supabase Auth).
19.1 Overview
| Comparison | Internal App (§17) | External App (this chapter) |
|---|---|---|
| Account System | Main site Supabase Auth | Independent custom_app_users table |
| Token Type | Supabase JWT | Custom JWT (HS256) |
| Account Sharing | Shared with main site Dashboard | Independent per App |
| Social Login | ❌ | ✅ LINE / Google / LIFF |
| Anonymous Browsing | ❌ | ✅ Combined with §18 Public mode |
External App user data is stored in the custom_app_users table, isolated per App. The same Email can be registered separately in different Apps.
19.2 Unified Login / Registration URL
External Apps have login/registration page routes automatically mounted in the Runtime:
https://ai-go.app/externalAppRuntime/{slug}
Page routes are defined by the App's VFS. Typical routes include:
| Hash Route | Page | Description |
|---|---|---|
#/login | LoginPage.tsx | Login page |
#/register | RegisterPage.tsx | Registration page |
#/ | HomePage.tsx | Home page (after login) |
⚠️ App developers must create
LoginPage.tsxandRegisterPage.tsxin the VFS themselves. The Runtime provides the Auth SDK (window.__auth__) to call backend APIs.
19.3 Custom App Auth API
All endpoints are prefixed with /api/v1/custom-app-auth/{app_slug}/.
Register
POST /api/v1/custom-app-auth/{slug}/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "mypassword123",
"display_name": "John Doe"
}
Success Response (201):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "rt_abc123...",
"expires_in": 900,
"user": {
"id": "uuid",
"email": "user@example.com",
"display_name": "John Doe",
"is_active": true,
"created_at": "2026-06-11T08:00:00Z"
}
}
| Error Code | Description |
|---|---|
| 409 | Email already registered |
| 422 | Parameter validation failed |
Login
POST /api/v1/custom-app-auth/{slug}/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "mypassword123"
}
Success Response (200): Same format as the registration response.
| Error Code | Description |
|---|---|
| 401 | Incorrect email or password |
| 403 | Account has been deactivated |
Get Current User
GET /api/v1/custom-app-auth/{slug}/me
Authorization: Bearer {access_token}
Refresh Token
POST /api/v1/custom-app-auth/{slug}/refresh
Content-Type: application/json
{
"refresh_token": "rt_abc123..."
}
The old Refresh Token is revoked after use (Token Rotation), and the response includes a new Refresh Token.
Logout
POST /api/v1/custom-app-auth/{slug}/logout
Authorization: Bearer {access_token}
Content-Type: application/json
{
"refresh_token": "rt_abc123..."
}
19.4 User Management API (Admin)
The following endpoints require a platform account JWT + builder.access permission (not a Custom App Token):
| Operation | Method | Endpoint |
|---|---|---|
| List Users | GET | /api/v1/custom-app-auth/manage/{app_id}/users |
| Activate/Deactivate | PATCH | /api/v1/custom-app-auth/manage/{app_id}/users/{user_id} |
| Delete User | DELETE | /api/v1/custom-app-auth/manage/{app_id}/users/{user_id} |
List Users:
GET /api/v1/custom-app-auth/manage/{app_id}/users
Authorization: Bearer {platform_jwt}
Response:
[
{
"id": "uuid",
"email": "user@example.com",
"display_name": "John Doe",
"is_active": true,
"last_login_at": "2026-06-11T08:00:00Z",
"created_at": "2026-06-01T00:00:00Z"
}
]
Deactivate User:
PATCH /api/v1/custom-app-auth/manage/{app_id}/users/{user_id}
Authorization: Bearer {platform_jwt}
Content-Type: application/json
{
"is_active": false
}
19.5 OAuth Social Login (LINE, Google, etc.)
💡 External Apps support third-party OAuth social login, allowing users to log in directly via LINE, Google, or other accounts without manually entering Email and password.
Query Available Auth Providers
GET /api/v1/custom-app-oauth/{slug}/auth-providers
Response:
[
{ "provider": "google", "enabled": true },
{ "provider": "line", "enabled": true }
]
Initiate OAuth Authorization
GET /api/v1/custom-app-oauth/{slug}/google/authorize?redirect_uri=https://your-app.com/callback
Returns a 302 redirect to the Google/LINE authorization page.
OAuth Callback
GET /api/v1/custom-app-oauth/{slug}/google/callback?code=xxx&state=xxx
On success, returns a Token (same format as the login endpoint).
LINE LIFF Token Exchange
Applicable for LINE LIFF App embedded scenarios:
POST /api/v1/custom-app-oauth/{slug}/liff-swap
Content-Type: application/json
{
"liff_access_token": "LINE_LIFF_ACCESS_TOKEN"
}
19.6 Auth SDK Auto-Injection (Runtime)
For Apps with access_mode = "external", the Runtime automatically injects the following methods on window.__auth__:
// Login
const result = await window.__auth__.login(email, password);
// result: { access_token, refresh_token, expires_in, user }
// Register
const result = await window.__auth__.register(email, password, displayName);
// Logout (clears local Token + revokes Refresh Token)
await window.__auth__.logout();
// Get current Token (handles refresh automatically)
const token = await window.__auth__.getToken();
// Check if authenticated
const isAuth = window.__auth__.isAuthenticated();
// Subscribe to auth state changes (callback triggered on login/logout)
const unsubscribe = window.__auth__.onAuthChange((isAuth) => {
console.log('Auth state changed:', isAuth);
});
// Unsubscribe
unsubscribe();
// Get OAuth social login URL
const googleUrl = window.__auth__.getOAuthUrl('google', '#/dashboard');
// → /api/v1/custom-app-oauth/{slug}/google/authorize?return_path=%23%2Fdashboard
window.location.href = googleUrl; // Redirect to Google login
Quick Login Trigger
If you don't want to build a custom LoginPage, you can call window.__triggerLogin__() directly to trigger the platform's unified login page:
// Trigger login from any component (optionally provide a return path after login)
window.__triggerLogin__('#/booking'); // Redirect to #/booking after login
// Without a path, redirects to home after login
window.__triggerLogin__();
Route Restoration After Login
After a successful OAuth or __triggerLogin__ login, the Runtime automatically stores the pre-login path in window.__INITIAL_ROUTE__:
// In App.tsx, check if there is a route to restore
const initialRoute = (window as any).__INITIAL_ROUTE__;
if (initialRoute) {
window.location.hash = initialRoute;
}
The Auth SDK automatically updates
window.__APP_TOKEN__. After a successful login, all subsequent API calls (api.ts,db.ts) automatically include the new Token — no manual handling required.
Complete LoginPage Example
import React, { useState } from "react";
import toast from "react-hot-toast";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const result = await (window as any).__auth__.login(email, password);
toast.success(`Welcome back, ${result.user.display_name}!`);
// Token is auto-updated, reload to switch to authenticated API
window.location.hash = "#/";
window.location.reload();
} catch (err: any) {
toast.error(err.message || "Login failed");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleLogin}>
<h1>Login</h1>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
<p>
Don't have an account? <a href="#/register">Register now</a>
</p>
</form>
);
}
Complete RegisterPage Example
import React, { useState } from "react";
import toast from "react-hot-toast";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await (window as any).__auth__.register(email, password, name);
toast.success("Registration successful!");
window.location.hash = "#/";
window.location.reload();
} catch (err: any) {
toast.error(err.message || "Registration failed");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleRegister}>
<h1>Register</h1>
<input
type="text"
placeholder="Display Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password (min 6 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<button type="submit" disabled={loading}>
{loading ? "Registering..." : "Create Account"}
</button>
<p>
Already have an account? <a href="#/login">Go to Login</a>
</p>
</form>
);
}
19.7 Token Mechanism
| Item | Value |
|---|---|
| Access Token Validity | 15 minutes |
| Refresh Token Validity | 7 days |
| Signing Algorithm | HS256 |
| Token Rotation | ✅ Old token automatically revoked on each refresh |
| Multi-Device Login | ✅ Independent session per device |
Token Storage Location (managed automatically by Auth SDK):
localStorage:
__custom_app_access_token__ → Access Token
__custom_app_refresh_token__ → Refresh Token
The Auth SDK automatically calls the
/refreshendpoint to renew the Access Token before it expires. Developers do not need to handle Token refresh logic manually.
20. Package Management and Third-Party Dependencies
💡 Use Case: When a Custom App needs to use third-party JavaScript / TypeScript packages (such as date handling libraries, charting libraries, etc.), understanding the VFS compilation environment's package management mechanism is essential.
20.1 Runtime Built-in Modules
The following modules are globally provided by the Runtime page and do not need to be installed — just import them directly:
import React from "react";
import { createRoot } from "react-dom/client";
import { HashRouter, Routes, Route, Link } from "react-router-dom";
import { Search, Calendar, User } from "lucide-react";
import toast, { Toaster } from "react-hot-toast";
| Module | Version | Description |
|---|---|---|
react | ^18.x | React core |
react-dom | ^18.x | DOM rendering |
react-router-dom | ^6.x | Hash routing |
lucide-react | latest | Icon library |
react-hot-toast | latest | Toast notifications |
⚠️ These modules are marked as
--externalduring esbuild compilation and will not be bundled into the output. The Runtime page provides the global versions.
20.2 package.json Dependency Declarations
The package.json in the VFS is used to declare the App's dependencies. However, unlike traditional Node.js projects, the VFS environment does not execute npm install. Package resolution is handled entirely by esbuild at compile time.
{
"name": "my-custom-app",
"private": true,
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
package.jsonprimarily serves as a module resolution hint for esbuild. Built-in modules (react, etc.) are pre-declared in dependencies by default.
20.3 Adding Third-Party Packages
For pure JavaScript/TypeScript packages, you can use them directly within the VFS:
Method 1: Reference directly in source code
Suitable for small utility functions. Define them directly in a component:
// src/utils/date.ts — Custom date formatting implementation
export function formatDate(date: string): string {
const d = new Date(date);
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}
Method 2: Place the package source code in the VFS
Suitable for small third-party libraries. Place the minified JS directly into the VFS:
src/
├── vendor/
│ └── dayjs.min.js ← Place the package source in the VFS
├── pages/
│ └── EventPage.tsx ← import dayjs from '../vendor/dayjs.min'
Note: Large packages (e.g., chart.js, three.js) are not recommended for placement in the VFS, as the single file size limit is 1MB.
20.4 Limitations and Considerations
| Limitation | Description |
|---|---|
| No CSS Modules | All CSS uses the global App.css; *.module.css is not supported |
| No Tailwind CSS | esbuild does not execute the PostCSS pipeline |
| No Node.js Native Modules | fs, path, crypto, etc. cannot run in the browser |
| No Dynamic Imports | import() syntax is not supported; all modules must be statically imported |
| Single File Size Limit | 1 MB |
| Max VFS Files | 500 |
| Compile Timeout | 30 seconds |
20.5 Common Package Compatibility
| Package | Compatible | Notes |
|---|---|---|
| date-fns (source import) | ✅ | Pure JS, tree-shakable |
| lodash-es (source import) | ✅ | ESM version |
| uuid | ✅ | Pure JS |
| chart.js | ⚠️ | Minified version must be < 1MB |
| three.js | ❌ | Too large (> 1MB) |
| styled-components | ❌ | Requires Babel transform |
| @mui/material | ❌ | Too many dependencies, requires emotion |
Further Reading
- AI GO System Integration Guide — API Key integration for third-party self-built applications
- Custom App supports Internal, External, and Public (Anonymous Access) modes