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

FeatureInternalExternalPublic (Anonymous)
Use CaseInternal management toolsExternal customer/supplier appsProduct catalogs, venue showcases, public pages
AuthenticationPlatform account loginIndependent account systemNo login required (anonymous)
API AccessFull (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

  1. Use React 18 + TypeScript + HashRouter (If the App is a single page, render components directly without a Router).
  2. React / ReactDOM / lucide-react / react-router-dom are provided by the Runtime and cannot be installed manually.
  3. Use a global App.css for CSS. CSS Modules or Tailwind are not supported.
  4. The entry point must be src/main.tsx.
  5. Server-Side Actions must be written in Python and placed in the actions/ directory.
  6. ⚠️ The Runtime executes in a Shadow DOM — CSS variables must use the :host, :root dual selector, not just :root (See Chapter 11).
  7. ⚠️ 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

FileDescription
src/api.tsCustom Data CRUD SDK
src/db.tsDB Proxy SDK
src/action.tsServer Action SDK
src/data.jsonAuto-injected at Runtime
src/db.jsonAuto-injected at Runtime

5. Code Injection API

API Endpoints Overview

OperationHTTP MethodEndpoint
Get App (inc. VFS)GET/api/v1/builder/apps/{slug}
Full VFS OverwritePUT/api/v1/builder/apps/{id}/source
Partial File UpdatePATCH/api/v1/builder/apps/{id}/source/files
Delete FilesDELETE/api/v1/builder/apps/{id}/source/files
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_version when getting the App.
  • Pass expected_version during 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

LimitValue
Max Files200
Max Single File Size1 MB
Compile Timeout30 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 an insert() 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:

  1. approved_state_field: The status field name to update (e.g., "state", "status", "doc_status").
  2. approved_state_value: The value to write upon approval (e.g., "approved", "validate", "done").
  3. rejected_state_value: The value to write upon rejection (e.g., "rejected", "draft").

How it works:

  • When the frontend submits an insert operation, the record is directly stored in the database with a draft or pending status, 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

MethodDescription
ctx.paramsParameters 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

IssueSolution
White ScreenVerify that React is mounted correctly in src/main.tsx
Routing not workingUse HashRouter, do not use BrowserRouter
Page cannot scrollShadow DOM container must set height: 100vh; overflow-y: auto (See Chapter 11)
CSS not appliedAdd 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 500This 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 401Token may be expired or SDK variables are improperly injected. Verify SDKs are not manually modified and Runtime started correctly
409 ConflictVFS modified concurrently. GET the latest, merge, and retry
423 LockedPending publish request. Wait for approval or cancel it
Action timeoutOptimize logic to complete within 30 seconds
Forbidden module importUse whitelisted modules or ctx.http.call()
pub/ API returns 403Verify allow_anonymous_access=true and the table has is_public_readable=true (see §18)
pub/ API returns 429Exceeded 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

SelectorStandalone HTML PageShadow 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 to html, :host {
  • Ensure Dark Mode @media blocks 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":

APIShadow DOM BehaviorAlternative
confirm()Silently returns falseReact useState two-step confirmation
alert()Does not displayreact-hot-toast or custom Toast
prompt()Returns nullReact 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 HashRouter when 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"

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

OperationHTTP MethodEndpointDescription
List TablesGET/api/v1/data/objects?app_id={app_id}Includes field definitions
Create TablePOST/api/v1/data/objectsSingle table creation
Create Table + FieldsPOST/api/v1/data/objects/batchRecommended: Create table + define fields at once
Delete TableDELETE/api/v1/data/objects/{obj_id}Cascading deletion of fields + records
Add FieldPOST/api/v1/data/objects/{obj_id}/fields
List FieldsGET/api/v1/data/objects/{obj_id}/fields
Modify FieldPATCH/api/v1/data/fields/{field_id}name / field_type / is_required / sequence
Delete FieldDELETE/api/v1/data/fields/{field_id}

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 }
  ]
}

fields are 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_typeDescriptionExample Values
textText"Hello"
numberNumber42, 3.14
dateDate"2026-04-23"
relationRelationRecord 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

LimitValue
Max tables per App20
Max fields per table50

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

  1. Tag Injection (Insert): When executing insert via SDK db.ts or Server-Side Actions, force the custom_data.app_domain injection.

    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" 
        }
      }
    });
    
  2. Forced Filtering (Query): For all query actions, 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 ilike or advanced JSONB operators is a common workaround; native JSON structure filtering will be supported by the platform in the future.

  3. Whitelist Isolation (AppDataReference): When establishing an App's DB Proxy authorization (app_data_references table), 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

OperationHTTP MethodEndpoint
Upload FilePOST/api/v1/ext/storage/upload
Get File URLGET/api/v1/ext/storage/url?path={path}
Delete FileDELETE/api/v1/ext/storage/file?path={path}
List FilesGET/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

LimitValue
Max File Size100 MB
Storage Bucketfiles (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 local and --env cloud. Pushing code to the wrong environment keys will trigger a 500 - Failed to load template from Storage outage 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/files to update all dependent VFS files in a single pass, and append a GET check before the command finishes to confirm whether the VFS File Count matches.

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:
    1. No matter how minor the update is, every overwrite of a single VFS file must provide 100% of the complete source code.
    2. Leaving // Code omitted here or // ... in the source code is strictly prohibited.
    3. 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 404 if 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 ItemRejection Response
App exists and is publishedhas_access: false, reason: "App does not exist or is unpublished"
User belongs to same orghas_access: false, reason: "Your account does not belong to the organization hosting this application"
User role in allowed listhas_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_url starts 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:

  1. Switch to Registration Mode — Header displays "Register {App Name}".
  2. Lock Email Field — Pre-fills the invited Email, making it uneditable.
  3. Display Inviter Info — "You have been invited by '{Inviter}' to join '{Organization}'."
  4. Invitee only needs to fill in Name and Password to complete registration.
  5. Upon success, auto-login occurs, redirecting straight to the App Runtime.

17.6 Error Handling

ScenarioPage 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

FieldRequired ValueDescription
status"published"App must be published
allow_anonymous_accesstrueEnable 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

EndpointMethodDescription
/api/v1/pub/data/{slug}/objectsGETList public tables (including field definitions)
/api/v1/pub/data/{slug}/objects/{obj}/recordsGETList 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 an api_slug (e.g., venues) or a UUID.

SaaS Reference Anonymous Read-Only

EndpointMethodDescription
/api/v1/pub/proxy/{slug}/{table}GETSimple query
/api/v1/pub/proxy/{slug}/{table}/queryPOSTAdvanced 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 limit cap 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:

VariableDescriptionAnonymous Mode ValueAfter Login Value
window.__APP_TOKEN__JWT Access Token"" (empty string)"eyJ..."
window.__APP_SLUG__App's slugHas valueHas value
window.__APP_ID__App UUIDHas valueHas value
window.__API_BASE__API base URLHas valueHas value
window.__IS_AUTHENTICATED__Whether authenticatedfalsetrue

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 ScopeLimitBasis
/api/v1/pub/data/* + /api/v1/pub/proxy/*120 requests / minuteper IP
/api/v1/custom-app-auth/* (POST)10 requests / minuteper IP
Authenticated /api/v1/data/* + /api/v1/proxy/*600 requests / minuteper 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

ProtectionMechanism
Column Whitelistfilters, search_columns, and select_columns are all validated against the allowed_columns whitelist; querying unauthorized columns is forbidden
Limit Cappub/proxy limit max is 100; pub/data is validated by FastAPI Query
Read-Onlypub/ endpoints only allow GET and POST query; INSERT / UPDATE / DELETE are not permitted
SQL InjectionAll parameters are bound via SQLAlchemy; no SQL string concatenation
App ValidationEvery request validates that the slug corresponds to an App that exists, is published, and allows anonymous access
Usage LoggingEvery 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

IssueSolution
pub/ API returns 404Verify that the App is published (status = published) and the slug is correct
pub/ API returns 403Verify allow_anonymous_access = true and the corresponding table has is_public_readable = true
pub/data doesn't show certain tablesThe 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 loginAfter login, the SDK uses the authenticated API; verify that the authenticated Data Reference is also properly configured
Cannot submit forms in anonymous modeExpected behavior — pub/ API only allows reads; form submission requires logging in
Rate Limit 429 errorAnonymous mode limits to 120 requests/min per IP; consider adding request deduplication and caching on the frontend
__IS_AUTHENTICATED__ is always falseVerify 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

ComparisonInternal App (§17)External App (this chapter)
Account SystemMain site Supabase AuthIndependent custom_app_users table
Token TypeSupabase JWTCustom JWT (HS256)
Account SharingShared with main site DashboardIndependent 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 RoutePageDescription
#/loginLoginPage.tsxLogin page
#/registerRegisterPage.tsxRegistration page
#/HomePage.tsxHome page (after login)

⚠️ App developers must create LoginPage.tsx and RegisterPage.tsx in 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 CodeDescription
409Email already registered
422Parameter 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 CodeDescription
401Incorrect email or password
403Account 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):

OperationMethodEndpoint
List UsersGET/api/v1/custom-app-auth/manage/{app_id}/users
Activate/DeactivatePATCH/api/v1/custom-app-auth/manage/{app_id}/users/{user_id}
Delete UserDELETE/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

ItemValue
Access Token Validity15 minutes
Refresh Token Validity7 days
Signing AlgorithmHS256
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 /refresh endpoint 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";
ModuleVersionDescription
react^18.xReact core
react-dom^18.xDOM rendering
react-router-dom^6.xHash routing
lucide-reactlatestIcon library
react-hot-toastlatestToast notifications

⚠️ These modules are marked as --external during 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.json primarily 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

LimitationDescription
No CSS ModulesAll CSS uses the global App.css; *.module.css is not supported
No Tailwind CSSesbuild does not execute the PostCSS pipeline
No Node.js Native Modulesfs, path, crypto, etc. cannot run in the browser
No Dynamic Importsimport() syntax is not supported; all modules must be statically imported
Single File Size Limit1 MB
Max VFS Files500
Compile Timeout30 seconds

20.5 Common Package Compatibility

PackageCompatibleNotes
date-fns (source import)Pure JS, tree-shakable
lodash-es (source import)ESM version
uuidPure JS
chart.js⚠️Minified version must be < 1MB
three.jsToo large (> 1MB)
styled-componentsRequires Babel transform
@mui/materialToo 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