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 in an isolated runner dedicated to your App (see Chapter 8).
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
Tenant Space URL (the common prefix for all APIs)
All APIs and logins go through the tenant space URL:
https://{tenant}.ai-go.app/*
{tenant} is the identifier of the tenant you belong to — it is the first segment of the address bar when users log into the platform (e.g., the acme in https://acme.ai-go.app/dashboard). Substitute your actual tenant for https://{tenant}.ai-go.app in every endpoint example in this document.
⚠️ Using the wrong tenant (including the apex
https://ai-go.app) presents as a 401 "Incorrect username or password" — identical in shape to a wrong password (a deliberate anti-account-enumeration design). When you hit a 401, check the tenant prefix in the URL first instead of going down the password rabbit hole.
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://{tenant}.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 → Data Center custom table 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: Data Center custom table CRUD
│ ├── db.ts # SDK: DB Proxy
│ ├── action.ts # SDK: Server-Side Action
│ ├── approval.ts # SDK: Approval operations (Internal App only)
│ ├── user.ts # SDK: Current user's roles/permissions (Internal App only)
│ ├── 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
├── _shared/ # Code shared between actions (not actions; no execute)
│ └── money.py
└── example_action.py # Action implementation
actions/_shared/: Sharing Code Between Actions
Actions cannot import one another — from other_action import ... will not work, because actions are not modules. To share logic, put it in actions/_shared/:
# actions/_shared/money.py — not an action; no execute(ctx) needed
def round_to_cents(value):
return round(value + 1e-9, 2)
# actions/settle_overpayment.py
from _shared.money import round_to_cents
def execute(ctx):
total = round_to_cents(sum(x["amount"] for x in ctx.params["items"]))
ctx.response.json({"total": total})
Files under _shared/ do not need to be (and should not be) registered in actions/manifest.json — they are not actions, are never dispatched, and produce no public endpoint.
Unmodifiable SDK Files
| File | Description |
|---|---|
src/api.ts | Data Center custom table CRUD SDK |
src/db.ts | DB Proxy SDK |
src/action.ts | Server Action SDK |
src/approval.ts | Approval SDK (Internal App only — see Chapter 23) |
src/user.ts | User Context SDK (current user's roles/permissions, Internal App only — see Chapter 7) |
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
Data Center Custom Tables (src/api.ts)
Work with tenant-level custom tables — created by an administrator in the Data Center and shared across the whole tenant (structure spec in Chapter 13):
import { listTables, queryTable, insertRow, updateRow, deleteRow } from "../api";
// List every custom table in this tenant (with field definitions)
const tables = await listTables();
// Query: returns a pagination envelope { items, total, page, page_size }
const page = await queryTable("orders", {
filters: [{ field: "status", op: "eq", value: "open" }], // op ∈ eq / contains / gte / lte
sort: "-created_at", // <physical name> ascending, -<physical name> descending, single column
page: 1,
page_size: 25,
});
await insertRow("orders", { customer_name: "Alice", amount: 1200 });
await updateRow("orders", rowId, { status: "closed" });
await deleteRow("orders", rowId);
- Always address tables and fields by physical name, never the display name — display names can be renamed at any time.
- The SDK routes to
/data-centeror/ext/data-centerautomatically based onwindow.__IS_EXTERNAL__. - The SDK has no structural operations: App runtime cannot create tables or alter fields. For new tables or columns, follow the admin flow in Chapter 13.
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:
// Wrong: 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!
Further reading:To build a pending-approvals list, approve/reject buttons, or an approval progress view inside your App, see Chapter 23 — Approval Workflow.
Approval (src/approval.ts)
Operate the platform approval system (Internal App only). Every function acts as the currently logged-in user:
import { myPending, recordStatus, approve, reject, cancel } from "../approval";
const items = await myPending(); // My pending approvals
const status = await recordStatus("sale_orders", orderId); // Approval status of a record (null if none)
await approve(items[0].request_line_id, "Approved");
await reject(items[0].request_line_id, "Wrong amount");
await cancel(requestId, "Resubmitting"); // Requester only
For full field references, permission rules, and the Server Action side (ctx.approval), see Chapter 23.
User Context (src/user.ts)
Reads a read-only snapshot of the current user's roles and permissions for role-conditional UI — sourced from the same permission system as the main app, so you never need to fetch('/api/v1/auth/me') yourself.
The snapshot is injected by the Runtime page at load time (window.__USER_ROLES__ / window.__USER_PERMISSIONS__). Only Internal Apps receive it; in External Apps and anonymous rendering every query returns an empty array (the platform's permission structure is never leaked).
| Function | Description |
|---|---|
getCurrentUser() | { roles: string[], permissions: string[] } (read-only copy) |
getRoles() | Role names (display only) |
getPermissions() | Permission tags, e.g. ['sale.write', 'crm.read'] |
hasPermission(perm) | Whether the user holds a permission (system.admin always passes) |
hasAnyPermission(...perms) | Holds at least one |
hasAllPermissions(...perms) | Holds all |
isAdmin() | Whether the user holds system.admin |
import {
getCurrentUser, getRoles, getPermissions,
hasPermission, hasAnyPermission, hasAllPermissions, isAdmin,
} from "../user";
const me = getCurrentUser(); // { roles: [...], permissions: [...] }
if (hasPermission("sale.write")) {
// Show the "New Order" button
}
if (hasAnyPermission("crm.write", "crm.delete")) { /* ... */ }
if (isAdmin()) { /* holds system.admin — everything unlocked */ }
Always authorize on permission tags (
module.action), never on role names. Tenants can rename roles freely (rename "Sales" and your check silently breaks); permission tags are stable.system.adminis a master key that passes everyhasPermissioncheck. This matches the main app'susePermissions()behavior.
Showing/hiding in the frontend is UX, not a security boundary. Frontend hiding can be bypassed. If different roles must see materially different data, branch on
ctx.user_permissionsinside a Server Action (see Chapter 8) or enforce it through Data Reference grants — the backend is the enforcement point.
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.app_id / ctx.tenant_id | UUIDs of the running app and tenant (strings) |
ctx.action_name | Name of the action currently executing |
ctx.env | Runtime environment (online / dev) |
ctx.user_id | Triggering user's UUID (string) |
ctx.user_roles | Triggering user's role names (list[str], read-only snapshot; empty for scheduled runs with no user context. Display only) |
ctx.user_permissions | Triggering user's permission tags (list[str], e.g. ['sale.write']). The backend enforcement point for role-conditional logic — hiding things in src/user.ts is only UX |
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. Writes to ERP tables are subject to approval control (see Chapter 23) |
ctx.db.list_tables() | List this tenant's custom tables (with field definitions) |
ctx.db.query_table(table, options) | Query custom-table records; returns a pagination envelope (filters / sort / page / page_size) |
ctx.db.insert_row(table, data) | Insert a custom-table record (takes a flat dict — do not wrap in {"data": ...}) |
ctx.db.update_row(table, row_id, data) | Update a custom-table record |
ctx.db.delete_row(table, row_id) | Delete a custom-table record |
ctx.approval.list_pending() | Pending approvals for the user who triggered this action |
ctx.approval.get_record_status(res_model, res_id) | Approval status of a record (including per-stage lines) |
ctx.approval.approve(request_line_id, comment=None) | Approve one stage (must be a qualified reviewer for that stage) |
ctx.approval.reject(request_line_id, comment=None) | Reject (must be a qualified reviewer for that stage) |
ctx.approval.cancel(request_id, reason=None) | Cancel an approval request (requester only) |
ctx.erp.* | Trigger the ERP business engines (confirm_sale_order, create_invoice, validate_picking, etc. — see below) |
ctx.knowledge.search(query, top_k=5, score_threshold=None) | Search the enterprise Knowledge Center; returns chunk-level results with relevance scores (retrieval only, no LLM — see Chapter 24) |
ctx.knowledge.get_content(file_id) | Full parsed text of one knowledge file |
ctx.http.call(service, path, method="GET", body=None, headers=None, params=None) | Call an external API through an authorized egress service. The third positional argument is method, not the body; does not raise — check status (see Chapter 25) |
ctx.http.fetch(url, ...) | Fetch an arbitrary public URL (SSRF protections still fully apply) |
ctx.messaging.list_channels() / add_channel() / update_channel() / remove_channel() | Channel management |
ctx.messaging.inject_inbound() / save_ai_outbound() / get_ai_config() | Communication center message I/O |
ctx.mcp.execute(task, wait=True) / trigger(task) / get_status(task_id) | MCP task execution (sync / async / status) |
ctx.secrets.get(key) / list_keys() | Get a secret value / list available secret names |
ctx.crypto.hash(alg, data) | Hash calculation |
ctx.crypto.hmac_sign(key_name, data) | HMAC signature (takes a key name, not a key value) |
ctx.crypto.base64_encode(data) / base64_decode(data) | Base64 |
ctx.crypto.aes_encrypt(key_name, plaintext) / aes_decrypt(key_name, ct, iv) | AES-256 |
ctx.response.json(data) | JSON response |
ctx.response.file(content, filename, mime) | File download response |
ctx.csv.export(rows, columns=None, filename=None) | Export CSV |
ctx.erp.* method list
| Method | What it triggers |
|---|---|
confirm_sale_order(id) / confirm_purchase_order(id) | Confirm an order (cascades into invoicing and fulfillment readiness) |
create_invoice(order_id) | Issue a customer invoice from a sales order. order_id accepts a list — several orders consolidate into one invoice (same customer, same tenant) |
create_bill(order_id) | Create a vendor bill from a purchase order for the received-but-unbilled difference only; also supports list merging |
validate_picking(id) | Validate a picking (cascades into stock moves and real-time valuation) |
post_move(id) | Post a journal entry |
confirm_payment(id) | Confirm a payment (cascades into FIFO reconciliation) |
reconcile_payment(id) | Re-run reconciliation for an already-posted payment |
confirm_payroll_run(id) / cancel_payroll_run(id) | Confirm / cancel a payroll run (cancel auto-generates reversing entries). Returns a dict with the generated move info |
Further reading:For identity rules, scope grants, and exception handling of
ctx.approval.*, see Chapter 23 — Approval Workflow. Further reading:ctx.dbhas no structural operations — Action runtime cannot create tables or alter fields; that boundary is intentional (see Chapter 13). Further reading:Everyctxmethod maps to a scope that a tenant administrator must approve; high-risk scopes additionally require an owner password step-up. See Chapter 26.
Branching on Permissions (the enforcement point)
Showing/hiding in src/user.ts is UX only; sensitive data must be branched on ctx.user_permissions inside the action:
def execute(ctx):
perms = ctx.user_permissions or []
is_admin = "system.admin" in perms
rows = ctx.db.query("sale_orders", limit=100)
if not (is_admin or "hr.read" in perms):
# Strip cost/margin columns for users without the permission
rows = [{k: v for k, v in r.items() if k not in ("cost", "margin")} for r in rows]
ctx.response.json({"rows": rows})
Branch on permission tags (module.action), not on role names from ctx.user_roles — tenants can rename roles. For scheduled runs with no user context both lists are empty; decide explicitly whether that should pass or fail.
Execution Isolation and Resource Limits
The "language-level sandbox" described in older documentation no longer exists: the import allowlist, the ban on
exec/eval/open, builtins restrictions, and the ban onclasssyntax have all been removed. This section is authoritative.
Isolation is now enforced in three layers outside the language itself:
- Dedicated runner pod: each App's Actions execute in their own runner pod (single concurrency). The pod is the isolation boundary, and tenants are separated by independent node scheduling.
- Network policy: the pod denies all ingress and egress by default, allowing only DNS, callbacks to the platform backend, and outbound 443 (excluding in-cluster private ranges).
ctxRPC allowlist: platform data and capabilities are reachable only throughctxmodule methods, and every call is validated against a backend allowlist — anything outside it returns 403. You may write arbitrary Python inside the pod, but the platform surface you can touch stays bounded by this list.
What this means for your code
- You can import any module installed in the runner image, and use
classsyntax and the full builtins freely. - Pre-publish static checks are structural only: the code must parse and must define
execute(ctx). Undefined-name errors surface at runtime — always test before publishing. print()is captured into the execution log returned with the result; it does not go to stdout.
Resource limits
| Limit | Value |
|---|---|
| Execution timeout | 30 seconds (configurable via manifest timeout_ms, capped at 30s); 3-second grace period before soft termination |
| Memory | 256 MB, advisory (exceeding it logs a warning; the run is not killed) |
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 |
| 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 |
Write returns approval_status: "pending" | The operation was intercepted by an approval workflow — neither success nor failure (see Chapter 23) |
| Action raises an "approval required" exception | An approval pre-guard on ctx.erp / ctx.db; the platform executes it automatically once approved, so do not retry (see Chapter 23) |
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 | Yes | 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);
// Wrong: 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>
);
}
// Wrong: 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>
);
}
// Wrong: 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).
Platform Identity Banner (rolling out to tenants since Sept 2026)
The platform identity marker is now a full-width top banner: "Third-party application | Hosted by the AI GO platform, not an official page". It behaves as follows:
- Shown only on each user's first visit, and auto-dismisses after 5 seconds.
- Does not intercept clicks (
pointer-events: none), so it never blocks the App's interactive elements. - Your App does not need to reserve layout space for it; but do not try to remove it via CSS/DOM either — it has a tamper-proof self-healing mechanism and any removal is automatically reverted.
12. VFS Injection Script Development Guidelines
Note: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.
Path Normalization (rolling out to tenants since Sept 2026)
On VFS writes the server normalizes file paths:
- Invalid paths are rejected outright with 400 "Invalid file path": paths containing backslashes
\, starting with/, or containing..segments are refused. - Case and extension folding is automatic:
Actions/→actions/,_Shared/→_shared/,.PY→.py. - Equivalent paths resolve to the same file:
actions/./foo.pyandactions/foo.pyare the same file — no duplicates are created.
13. Data Center: Tenant-Level Custom Tables
Use case: your App needs a business entity the platform doesn't have (cases, shifts, surveys…). That data belongs in a Data Center custom table — a real, tenant-level table, not an App-private dynamic structure.
13.1 Core Semantics: Bound to the Tenant, Not the App
Custom tables belong to the tenant. Every Custom App in that tenant, plus the Data Center UI, sees the same tables and the same rows.
So always inventory before creating. Two Apps each creating their own "Customers" table means the data is split in two, and merging it afterwards is painful.
1. GET /api/v1/data-center/tables ← inventory the tenant's tables (never skip)
2. Semantically equivalent table exists? → reuse it, do not create another
3. Need a new table → produce a spec and have the tenant admin confirm it
4. POST /api/v1/data-center/tables
├─ 201 → GET to verify, continue building
└─ 403 → your account lacks datacenter.schema_write (and is not system.admin):
do not retry, do not route around it.
Output a copy-pasteable table spec, ask someone with the permission
to create it in the Data Center UI, then GET /tables to verify
before continuing.
13.2 Permissions: Structure and Data Are Governed Separately
Structural permissions are now split in two (rolling out to tenants since Sept 2026):
| Operation | Permission required |
|---|---|
| Create/alter table, add/alter field | datacenter.schema_write (system.admin passes through) |
| Drop table, drop field | system.admin (tenant administrator) |
| Read structure (list, read schema) | builder.access |
| Record CRUD (query/insert/update/delete) | builder.access |
No default role includes
datacenter.schema_write— the tenant owner must check it for the relevant role in the role management UI before it takes effect. When you get a 403, verify this step first.
This is a deliberately narrow governance line: the platform governs the shape of the schema, not its use. App runtime (src/api.ts, ctx.db) has no structural operations — it cannot create tables or alter fields, and that is an intentional capability boundary.
13.3 Two Names: Display Name vs Physical Name
| Display name | Physical name | |
|---|---|---|
| Chosen by | You | Generated by the system from the display name |
| Mutable | Yes, any time | Never, once created |
| Character set | Anything (often Chinese) | ASCII only |
| Used for | UI rendering | API identity, deletion confirmation value |
Every API refers to existing tables and fields by physical name. Renaming the display name breaks nothing.
One exception: a
relationfield pointing at a custom table usestarget_table_id— the target table's UUID (theidfromGET /tables), not its physical name.
Reserved physical names (rolling out to tenants since Sept 2026): the reserved list has been extended to the platform's floor table names (users, tenants, audit_logs, api_keys, etc.). If the physical name generated at creation collides with a reserved name, the API returns 409 "conflicts with a platform-reserved table name" — avoid generic system words when picking display names (e.g., use "Project Members" rather than "Users").
13.4 Field Types
| Type | Description | Extra contract |
|---|---|---|
text | Text | |
number | Numeric | |
boolean | Boolean | |
date | Date | |
datetime | Timestamp | |
select | Single choice | Option set required; values are CHECK-constrained |
relation | Relation | See below |
json | Structured data | |
image | Image | Stores a storage key — see 13.7 |
System fields id / created_at / updated_at are added automatically; they cannot be deleted or retyped and do not count against the field quota.
A relation targets exactly one of two things
- → a custom table:
target_table_id= the target's UUID. Creates a real database foreign key; deleting a row that is still referenced is blocked (409, withdetail.dependentslisting the dependents). - → an ERP table:
target_erp_key= the ERP table key. A soft relation with no foreign key (it crosses a schema boundary); the target is validated on write.
Exactly one of the two — supplying both or neither is an error. Neither can be changed after creation.
13.5 Quotas
| Free tier | Paid tier | |
|---|---|---|
| Tables per tenant | 20 | 200 |
| Non-system fields per table | 50 | 100 |
- Exceeding a quota returns 409 (never a silent truncation).
- A single
POST /tablesaccepts at most 50 fields (a schema-layer cap), which is not the same thing as the per-table quota: a paid tenant trying to create 60 fields at once gets 422, not 409 — create the table first, then add fields viaPOST /tables/{key}/fields. - ERP extension fields draw on the same field quota.
13.6 Deletion Is a Two-Step Operation
Dropping tables and fields is irreversible, so the server enforces two steps:
- Impact preview:
GET /tables/{key}/impactorGET /tables/{key}/fields/{field_key}/impact→ record counts, per-field non-null statistics, and whether other tables depend on it through relations - Confirmed execution: the confirmation value goes in the
confirmquery parameter and must equal the physical name (display names are mutable, so using one as a confirmation value is no confirmation at all)
DELETE /api/v1/data-center/tables/{key}?confirm={table physical name}
DELETE /api/v1/data-center/tables/{key}/fields/{field_key}?confirm={field physical name}
13.7 Image Fields
An image field stores a storage key, not a URL. URLs are valid for one hour; storing one in a field means storing something that expires.
| Action | Endpoint | Contract |
|---|---|---|
| Upload | POST /api/v1/data-center/tables/{key}/images | Returns a storage key plus a directly displayable URL; store the key |
| Get URL | GET /api/v1/data-center/images/url | Pass the key, get a short-lived signed URL; re-fetch on every render |
PNG/JPEG/GIF/WebP are allowed, up to 10 MB per file; SVG is deliberately excluded (it can embed scripts). Images appear in the File Explorer's "Data Center images" folder and users may delete them — the field then renders "image removed", which is a known trade-off.
13.8 API Reference (main app, as a logged-in user)
| Operation | Method | Endpoint | Permission |
|---|---|---|---|
| List | GET | /api/v1/data-center/tables | builder.access |
| Read one | GET | /api/v1/data-center/tables/{key} | builder.access |
| Create table | POST | /api/v1/data-center/tables | datacenter.schema_write |
| Update table (display name…) | PATCH | /api/v1/data-center/tables/{key} | datacenter.schema_write |
| Drop-table impact | GET | /api/v1/data-center/tables/{key}/impact | builder.access |
| Drop table | DELETE | /api/v1/data-center/tables/{key} | system.admin |
| Add field | POST | /api/v1/data-center/tables/{key}/fields | datacenter.schema_write |
| Alter field | PATCH | /api/v1/data-center/tables/{key}/fields/{field_key} | datacenter.schema_write |
| Drop-field impact | GET | /api/v1/data-center/tables/{key}/fields/{field_key}/impact | builder.access |
| Drop field | DELETE | /api/v1/data-center/tables/{key}/fields/{field_key} | system.admin |
| Query records | GET | /api/v1/data-center/tables/{key}/records | builder.access |
| Insert record | POST | /api/v1/data-center/tables/{key}/records | builder.access |
| Update record | PATCH | /api/v1/data-center/tables/{key}/records/{record_id} | builder.access |
| Delete record | DELETE | /api/v1/data-center/tables/{key}/records/{record_id} | builder.access |
Create-table example
POST /api/v1/data-center/tables
Authorization: Bearer {JWT}
Content-Type: application/json
{
"display_name": "Cases",
"fields": [
{ "display_name": "Case No.", "field_type": "text" },
{ "display_name": "Status", "field_type": "select", "options": ["open", "closed"] },
{ "display_name": "Owner", "field_type": "relation", "target_erp_key": "employees" }
]
}
13.9 Four Access Surfaces
| Caller | Prefix | Capabilities |
|---|---|---|
| Main app / Builder (logged-in user) | /api/v1/data-center/ | Structure (datacenter.schema_write; deletion requires system.admin) + schema reads and record CRUD |
| Internal App runtime | same (handled by src/api.ts) | Schema reads + record CRUD |
| External App runtime | /api/v1/ext/data-center/ | Schema reads + record CRUD (no structural operations) |
| Third-party integration (API key) | /api/v1/open/data-center/ | Schema reads + record CRUD, scoped to the whole tenant |
| Anonymous viewing | /api/v1/pub/data-center/{slug}/ | Read-only, and only for tables flagged publicly readable (see Chapter 18) |
For in-App usage (src/api.ts and ctx.db) see Chapter 7 — Built-in SDKs; the SDK routes to /data-center or /ext/data-center automatically based on window.__IS_EXTERNAL__.
13.10 Tenant User Directory: GET /api/v1/users (rolling out to tenants since Sept 2026)
Custom tables often need to record "which user does this row belong to". The platform provides a tenant user directory endpoint:
GET /api/v1/users
Authorization: Bearer {JWT}
- Any logged-in account can call it (no extra permission required).
- The response is a paginated envelope, and each entry deliberately carries only three fields:
id,name,status— no email (a privacy design).
Current approach for referencing users from custom tables:
- Store the user's UUID in a
textfield (relationfields cannot targetusersyet). - When rendering, call
GET /api/v1/usersto resolve UUIDs into names.
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").
This applies to the SaaS-table track only. Data Center custom tables neither need nor should carry
app_domain— they have nocustom_datacolumn, and sharing one dataset across Apps is precisely their design goal, so tagging them apart is an anti-pattern (see Chapter 13).
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 Data Center custom tables: ideal for new business data the platform has no entity for (e.g., customer satisfaction surveys, shift schedules). Custom tables are tenant-level, so inventory the existing ones before creating another (see Chapter 13).
- 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": "<signed download URL, valid for one hour>",
"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.
15.3 Render a Skeleton/Loading Placeholder First on Startup (rolling out to tenants since Sept 2026)
The platform watches for 8 seconds after the App mounts: if the Shadow root is still completely empty, it automatically reports a runtime error and shows the user a banner reading "The App loaded but displayed no content".
- Correct approach: render a skeleton or loading placeholder immediately on startup, then fetch API data to fill it in.
- Pattern that gets falsely flagged: "run a long API call first and only do the initial render once the data arrives" — if the API takes more than 8 seconds, the App is judged content-less even though nothing is actually broken.
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://{tenant}.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 (redirect_url support is rolling out to tenants since Sept 2026):
POST /api/v1/members
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://{tenant}.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. Withoutredirect_url, invitees land on the main site Dashboard.
Permission: requires hr.member_manage. An account with only builder.access gets a 403.
redirect_url is a fail-closed allowlist — any violation of these rules returns 422:
| Rule | Description |
|---|---|
| Prefix allowlist | Limited to platform-known prefixes such as /app-login/, /runtime/, /dashboard/ |
| Character set | Pure ASCII [A-Za-z0-9/._~-] |
| Forbidden characters | Must not contain ? or # |
| Length | ≤ 256 characters |
Resending invitations: resend-invite also supports redirect_url; when omitted, the previous invitation's landing target is reused.
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
Internal Apps share the platform's own account system (JWT, managed by the backend and stored in localStorage). To log out, call the platform logout endpoint to revoke the refresh token, then clear the local token:
// Logout and redirect back to App login page
async function handleLogout() {
await fetch("/api/v1/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${(window as any).__APP_TOKEN__}`,
},
body: JSON.stringify({ all_devices: false }),
});
window.location.href = `/app-login/${APP_SLUG}`;
}
Explanation:
POST /api/v1/auth/logoutrevokes the refresh token server-side (passrefresh_tokento revoke that device only, orall_devices: truefor every device); the access token is stateless and simply expires. After logout, users can log back in at/app-login/{slug}.
Note: This logout also ends the user's main-site Dashboard session (it is the same platform account system).
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://{tenant}.ai-go.app/api/v1" # substitute your actual tenant for {tenant}
# 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; requires hr.member_manage)
resp = httpx.post(f"{BASE}/members", 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://{tenant}.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: Data Center Custom Tables
Each custom table individually controls whether it is exposed to the anonymous API (a table-meta update — requires datacenter.schema_write, system.admin passes through):
PATCH /api/v1/data-center/tables/{key}
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 Tables, Anonymous Read-Only
| Endpoint | Method | Description |
|---|---|---|
/api/v1/pub/data-center/{slug}/tables | GET | List tables flagged publicly readable (including field definitions) |
/api/v1/pub/data-center/{slug}/tables/{key}/records | GET | List records of a table (pagination envelope) |
Example: List Public Tables
GET /api/v1/pub/data-center/aeb47f756cef/tables
Response:
[
{
"id": "uuid",
"name": "Venues",
"physical_name": "venues",
"fields": [
{ "physical_name": "name", "display_name": "Name", "field_type": "text" },
{ "physical_name": "address", "display_name": "Address", "field_type": "text" }
]
}
]
Example: Query Records
GET /api/v1/pub/data-center/aeb47f756cef/tables/venues/records?page=1&page_size=25
{key}is the table's physical name (e.g.venues). The anonymous surface is deliberately minimal:sortandfiltersare not accepted,page_sizecaps at 100, and only whitelisted fields are returned (system fieldsid/created_atalways are).
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
}
Note: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 queryTable(key: string, options = {}): 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-center/${APP_SLUG}/tables/${key}/records?page=1&page_size=25`
);
return res.json();
}
// Logged in → Use standard authenticated API
const res = await fetch(`${API_BASE}/data-center/tables/${key}/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-center/* + /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://{tenant}.ai-go.app/api/v1" # substitute your actual tenant for {tenant}
# 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. Flag custom tables as publicly readable (requires datacenter.schema_write; system.admin passes through)
resp = httpx.get(f"{BASE}/data-center/tables", headers=headers)
for table in resp.json():
if table["key"] in ("venues", "products", "prices"):
httpx.patch(f"{BASE}/data-center/tables/{table['key']}", headers=headers, json={
"is_public_readable": True
})
print(f" ✓ {table['key']} → 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-center/{slug}/tables")
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 (the platform account system).
19.1 Overview
| Comparison | Internal App (§17) | External App (this chapter) |
|---|---|---|
| Account System | Platform account system | Independent custom_app_users table |
| Token Type | Platform JWT | Custom JWT (HS256) |
| Account Sharing | Shared with main site Dashboard | Independent per App |
| Social Login | No | LINE / Google / LIFF |
| Anonymous Browsing | No | 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://{tenant}.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) |
Note: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}
Update Your Own Display Name (rolling out to tenants since Sept 2026)
PATCH /api/v1/custom-app-auth/{slug}/me
Authorization: Bearer {access_token}
Content-Type: application/json
{
"display_name": "New Name"
}
- The payload accepts only
display_name: it must be non-empty after stripping whitespace and at most 100 characters; violations return 422. - Identity comes from the token — you can only rename yourself; renaming does not revoke existing sessions.
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.)
Tip: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 |
Note: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) | Yes | Pure JS, tree-shakable |
| lodash-es (source import) | Yes | ESM version |
| uuid | Yes | Pure JS |
| chart.js | Minified version must be < 1MB | |
| three.js | No | Too large (> 1MB) |
| styled-components | No | Requires Babel transform |
| @mui/material | No | Too many dependencies, requires emotion |
20.6 Python Action Dependencies: actions/requirements.txt (rolling out to tenants since Sept 2026)
Server-Side Actions (Chapter 8) can declare pip dependencies via actions/requirements.txt, which syncs bidirectionally with the Builder's "Packages" panel.
Declaration format and limits:
| Rule | Description |
|---|---|
| Exact pins only | One name==version per line (extras allowed, e.g. requests[socks]==2.32.3); version ranges, URLs, and local paths are all rejected |
| Line cap | At most 20 lines |
| Size cap | Combined wheel size ≤ 80 MiB |
| Target platform | aarch64 / cp312, only-binary — packages that would need on-the-spot compilation (no matching wheel) are unavailable |
Resolution timing and errors:
- Dependency resolution happens only at test-run/publish time; failures return 422 with
WHEELHOUSE_*error codes, and no version with missing packages is ever released. - With pins present, test runs use a dedicated draft runner with a cold start of up to ~60 seconds — a "draft runner not ready" message means it is still cold-starting; wait a moment and retry.
- pip stays forbidden at runtime: Action code cannot
pip install; every dependency must be pinned inrequirements.txtup front.
21. Reference System API
Prefix:
/api/v1/refsAuth: Main Site JWT (requiresbuilder.accesspermission)
A Reference is the core data authorization mechanism in AI GO. Every integration must first establish references specifying which tables and fields it intends to access, and what operational permissions it holds.
21.1 List Referencable Tables
GET /api/v1/refs/available-tables
Response 200 OK:
[
{ "name": "customers", "comment": "Customer" },
{ "name": "sale_orders", "comment": "Sales Order" },
{ "name": "product_products", "comment": "" }
]
21.2 Get Table Field Information
GET /api/v1/refs/tables/{table_name}/columns
Response 200 OK:
[
{
"name": "name",
"type": "VARCHAR",
"nullable": false,
"is_system": false
},
{
"name": "email",
"type": "VARCHAR",
"nullable": true,
"is_system": false
},
{
"name": "customer_id",
"type": "UUID",
"nullable": true,
"is_system": false,
"is_fk": true,
"fk_target": "customers.id"
}
]
21.3 Reference CRUD
List All References for an App
GET /api/v1/refs/apps/{app_id}
Create Reference
POST /api/v1/refs/apps/{app_id}
| Field | Type | Required | Description |
|---|---|---|---|
table_name | string | Required | Name of the table to reference, e.g., "customers" |
columns | string[] | Optional | List of authorized fields, e.g., ["name", "email", "phone"] |
permissions | string[] | Optional | List of permissions, e.g., ["read", "create"] |
Update Reference
PATCH /api/v1/refs/{ref_id}
| Field | Type | Description |
|---|---|---|
columns | string[] | New list of authorized fields |
permissions | string[] | New list of permissions |
Delete Reference
DELETE /api/v1/refs/{ref_id}
21.4 Permission Values Explanation
| Permission | Corresponding Proxy Operation | Description |
|---|---|---|
read | GET / POST query | Query data |
create | POST insert | Insert record |
update | PATCH | Update record |
delete | DELETE | Delete record |
21.5 Unreferencable Tables
System core tables (e.g., authentication, tenant management, permission settings, audit logs, etc.) can never be referenced due to security reasons. Attempting to reference these tables will return a 403 Table is not referencable error via the API.
Please use the GET /api/v1/refs/available-tables API to query the list of tables that can be referenced.
21.6 System Fields
The following fields are automatically managed by the system and will be automatically excluded when writing:
| Field | Type | Description |
|---|---|---|
id | UUID | Primary key, auto-generated |
created_at | timestamptz | Creation time, automatically set |
updated_at | timestamptz | Update time, automatically updated |
tenant_id | UUID | Tenant ID, auto-injected (row-level isolation) |
21.7 Shared Business Field: custom_data (JSONB)
All functional data tables contain a custom_data field, designed specifically for third-party applications to store custom data specific to their business operations.
| Feature | Description |
|---|---|
| Type | JSONB (PostgreSQL native JSON binary format) |
| Default Value | '{}'::jsonb (Empty JSON object) |
| Nullable | Yes (Can be set to null) |
| Access Method | Only via Proxy API (Internal / External / Open) |
| Data Isolation | Follows existing tenant_id row-level isolation mechanics |
Usage: Include custom_data in the columns list when creating the reference:
{
"table_name": "customers",
"columns": ["id", "name", "email", "custom_data"],
"permissions": ["read", "create", "update"]
}
Note:If
custom_datais not included in the referencedcolumns, the App will not see this field when reading, and it will be automatically ignored when writing.
22. Receiving External Webhooks
Use Case: When a Custom App needs to receive real-time notifications from third-party services (e.g., LINE, Meta, payment gateways, logistics providers), it can use the platform's generic Webhook Gateway.
22.1 Webhook URL
Each published Custom App automatically receives a Webhook URL:
POST https://{tenant}.ai-go.app/api/v1/custom-apps/webhook/{slug}
POST https://{tenant}.ai-go.app/api/v1/custom-apps/{slug}/webhook
Where {slug} is the App's slug or subdomain (not UUID).
No authentication required: The Webhook URL is a public endpoint — no JWT Token needed. The App must be in published state.
22.2 receive_webhook.py Action
The Webhook Gateway always dispatches to actions/receive_webhook.py — this name cannot be changed. This file must be listed in actions/manifest.json.
The ctx.params structure received by the Action:
{
"webhook_event": "incoming",
"body": "<raw HTTP Body string>",
"headers": {
"content-type": "application/json",
"x-line-signature": "...",
...
}
}
22.3 Implementation Example
import json
def execute(ctx):
"""Receive external Webhook and route by event type"""
# 1. Parse raw Payload
raw_body = ctx.params.get("body", "")
headers = ctx.params.get("headers", {})
try:
payload = json.loads(raw_body)
except json.JSONDecodeError:
ctx.response.json({"error": "Invalid JSON"})
return
# 2. Route by event type
event_type = payload.get("type") or payload.get("event")
if event_type == "order.created":
# New e-commerce order → write to orders table
ctx.data.insert("orders", {
"order_no": payload["order_id"],
"amount": payload["total"],
"status": "pending",
"raw_payload": raw_body,
})
elif event_type == "payment.confirmed":
# Payment confirmed → update order status
ctx.data.update("orders",
filters=[{"column": "order_no", "op": "eq", "value": payload["order_id"]}],
data={"status": "paid"}
)
elif event_type == "message":
# Chat message → write to messages log
ctx.data.insert("messages", {
"sender": payload.get("sender_id"),
"content": payload.get("text"),
"channel": headers.get("x-channel-id", "unknown"),
})
else:
# Unknown event → log for analysis
ctx.data.insert("webhook_logs", {
"event_type": event_type or "unknown",
"payload": raw_body,
"processed": False,
})
ctx.response.json({"status": "accepted"})
Action must be named
receive_webhook.py: The Webhook Gateway always dispatches to this Action; the name cannot be changed.
22.4 Limits
| Item | Description |
|---|---|
| App Status | Must be published |
| Execution Timeout | 45 seconds |
| Response Format | Gateway immediately returns {"status": "accepted"}; Action executes asynchronously |
| Retry Mechanism | Platform does not auto-retry; implement error logging in your Action |
| Signature Verification | Must be implemented in the Action (e.g., LINE Signature) |
22.5 Meta Webhook Subscription Verification
Set META_VERIFY_TOKEN in the App's Secrets — the platform automatically handles Meta's GET verification requests without triggering the Action.
Tip:For the complete Webhook Gateway specification (URL format, authentication, etc.), see AI GO Integration Guide — Webhook Gateway.
23. Approval Workflow
AI GO ships with a generic approval engine: administrators configure "which table, which action, and who signs off," and the engine intercepts the operation before it actually executes, turning it into an approval request instead. The engine is non-invasive — it does not alter table structures, and you never have to implement approval logic inside your App.
A Custom App touches the approval system in three places:
| Touchpoint | Description | Section |
|---|---|---|
| Being intercepted | Your write/confirm operation matches a workflow and becomes an approval request | 23.2 / 23.3 |
| Frontend SDK | src/approval.ts: build pending lists and approve/reject buttons in your App | 23.4 |
| Server Action | ctx.approval.*: query and act on approvals from Python | 23.5 |
Workflows themselves are configured by tenant administrators in the main AI GO app (Approval Workflow settings). A Custom App neither owns nor can create workflows. Your responsibility is to handle "intercepted" responses correctly and, optionally, to provide an approval UI.
23.1 Core Concepts
-
Pre-guard: before a state change, the operation asks the engine "does this need approval?" If it matches, an
ApprovalRequestis created and the original operation does not execute. If no workflow is configured, it passes straight through (short-circuit). -
Polymorphic linkage: a request points at any record via
(res_model, res_id)(table name + record ID). -
Callback closure: once every stage is approved, the platform automatically executes the operation that was intercepted (confirming the order, posting the journal entry, applying the stored update payload). Your code must not resubmit after approval.
-
State machine:
Request: pending → approved (all stages passed) | rejected (any stage rejected) | cancelled (by requester) Line: waiting → pending → approved | rejected | skipped -
After a rejection the original record stays editable; resubmitting creates a brand-new request (the old one is never revived).
23.2 Which Operations Get Intercepted
| Source | Controlled operations | Intercept behavior |
|---|---|---|
src/db.ts (DB Proxy) | insert / update / remove on authorized ERP tables | insert = insert-then-flag; update / delete = pre-guard |
ctx.db (Server Action) | insert / update / remove on ERP tables | Same as above; pre-guard raises ApprovalPendingError |
ctx.erp (Server Action) | confirm_sale_order, confirm_purchase_order, post_move, confirm_payment, validate_picking, confirm_payroll_run, and other forward-confirmation methods | Pre-guard, raises ApprovalPendingError |
| Main ERP endpoints | Sales/purchase confirmation, journal posting, picking validation, MRP confirmation, HR leave approval | HTTP 202 pending_approval |
Tables commonly placed under approval: sale_orders, purchase_orders, account_moves, account_payments, stock_pickings, stock_scraps, mrp_productions, hr_leaves, plus any other ERP table you have authorized for the App.
Out of scope: Data Center custom table writes (src/api.ts, ctx.db.*_row) do not touch ERP tables and are not subject to approval. Repair/reversal methods such as ctx.erp.reconcile_payment and cancel_payroll_run are not guarded either.
A Custom App is not an approval bypass. The same guards apply whether you go through the frontend SDK,
ctx.db, orctx.erp— do not attempt to route around a tenant's configured workflow by switching write paths.
23.3 How Your Code Should Handle an Intercept
(1) Insert — insert-then-flag: the record is still written
So that relationship IDs exist, an intercepted insert does write the record, but no downstream business logic fires; the response carries a pending marker:
const result = await insert("sale_orders", { data: { amount_total: 5000 } });
if (result.approval_status === "pending") {
// result.approval_request_id / result.approval_message are also returned
toast.info(result.approval_message || "Approval request submitted; effective once approved");
} else {
toast.success("Order created");
}
Never retry an insert because of
pending— you will create duplicate records and duplicate requests.
(2) Update / Delete — pre-guard: nothing happens, payload is stored
The update/delete does not occur. Your payload is stored on the approval request and applied automatically by the platform once every stage approves.
(3) Inside a Server Action: catch ApprovalPendingError
Pre-guards on ctx.db.update / ctx.db.remove and ctx.erp.* surface as exceptions:
def execute(ctx):
try:
ctx.erp.confirm_sale_order(ctx.params["order_id"])
except Exception as e:
# Message reads like: "requires approval ... (request_id=...), the system will execute it once fully approved"
if "簽核" in str(e) or "ApprovalPending" in type(e).__name__:
ctx.response.json({"pending_approval": True, "message": str(e)})
return
raise
ctx.response.json({"ok": True})
(4) No-code loop: let the engine flip your status field
If all you need is for the record's status field to become approved after sign-off, write no Python at all — ask the administrator to fill in approved_state_field / approved_state_value / rejected_state_value on the workflow, and the engine updates that field on approval/rejection (see Chapter 7 — Approval State Callbacks). Your App simply queries state === 'approved'.
23.4 Frontend Approval SDK (src/approval.ts)
Acts as the currently logged-in user. Internal App only — every function throws immediately in an External App (External Apps have no approval endpoints).
| Function | Description |
|---|---|
myPending() | My pending approvals (array; the {items,total} envelope is already unwrapped) |
recordStatus(resModel, resId) | Latest approval request for a record; null if none |
approve(requestLineId, comment?) | Approve one stage; must be a qualified reviewer, otherwise the backend returns 403 |
reject(requestLineId, comment?) | Reject; the whole request becomes rejected |
cancel(requestId, reason?) | Cancel a request; requester only, pending only |
Fields on each myPending() item:
| Field | Description |
|---|---|
request_id | Request ID (used by cancel) |
request_line_id | Stage line ID (used by approve / reject) |
res_model / res_id | Table name and record ID under approval (use these to load the source document) |
workflow_name / stage_name | Workflow name and current stage name |
requester_name | Requester email; sources without a user identity show API |
submitted_at / current_stage_sequence | Submission time and current stage sequence |
recordStatus() returns { id, status, workflow_name, requester_name, submitted_at, completed_at, lines[] }, where each entry in lines[] holds { id, sequence, stage_name, status, assigned_user_id, reviewer_id, comment, reviewed_at } — enough to render an approval timeline directly.
Full example: a pending-approvals page inside your App
import { useEffect, useState } from "react";
import { myPending, approve, reject } from "../approval";
export default function MyApprovalsPage() {
const [items, setItems] = useState<any[]>([]);
const load = async () => setItems(await myPending());
useEffect(() => { load(); }, []);
const onApprove = async (item: any) => {
try {
const res = await approve(item.request_line_id, "Approved");
// res.all_approved === true means the final stage passed and the platform already executed the original operation
alert(res.all_approved ? "Fully approved — operation executed" : "Approved; moving to the next stage");
await load();
} catch (e: any) {
alert(e.message); // 403: you are not a qualified reviewer for this stage
}
};
return (
<div>
{items.map((it) => (
<div key={it.request_line_id}>
<span>{it.workflow_name} — {it.stage_name} ({it.requester_name})</span>
<button onClick={() => onApprove(it)}>Approve</button>
<button onClick={() => reject(it.request_line_id, "Wrong amount").then(load)}>Reject</button>
</div>
))}
</div>
);
}
Tip:
myPending()only returns items you are qualified to sign. Eligibility is resolved server-side at request time (role/department changes take effect immediately), so the frontend neither needs nor should attempt to decide who may approve.
23.5 Server Action: ctx.approval.*
Operate approvals from Python as the user who triggered the action — never as the App itself.
| Method | Description |
|---|---|
ctx.approval.list_pending() | Triggering user's pending approvals (same fields as myPending()) |
ctx.approval.get_record_status(res_model, res_id) | Approval status of a record (with lines[]); None if there is none |
ctx.approval.approve(request_line_id, comment=None) | Approve one stage. Returns {status, request_id, request_status, all_approved, callback_error} |
ctx.approval.reject(request_line_id, comment=None) | Reject the whole request |
ctx.approval.cancel(request_id, reason=None) | Cancel a request (requester only) |
def execute(ctx):
pending = ctx.approval.list_pending()
if not pending:
ctx.response.json({"count": 0, "items": []})
return
# Illustrative: your App decides the business rule; the platform still enforces eligibility
result = ctx.approval.approve(pending[0]["request_line_id"], "Auto-approved by rule")
ctx.response.json({
"all_approved": result["all_approved"],
"callback_error": result["callback_error"], # not None → callback failed, retry from the main app
})
Three rules you cannot work around:
- An App cannot sign on anyone's behalf.
approve/rejectmust be bound to a platform user who is a qualified reviewer of the current stage (the same check used by the main app's endpoints). Otherwise:PermissionError. - Invocations without a user identity are always rejected. When a scheduled/background trigger carries no platform user,
list_pending/approve/reject/cancelraisePermissionError. cancelis requester-only, and only while the request ispending.
Scope grants: ctx.approval must be granted in the App's Scope settings, at differing risk tiers —
| Scope | Methods | Risk tier |
|---|---|---|
approval.read | list_pending, get_record_status | Low risk (no step-up) |
approval.decide | approve, reject | High risk (owner password step-up required when the grant is widened) |
approval.cancel | cancel | High risk (same) |
Tip:A non-
Nonecallback_errormeans approval completed but the automatic execution of the original operation failed (e.g. a missing accounting account). The approval result is not rolled back — tell the user to retry that callback from the main app's approval panel.
23.6 Workflow Rules (so you can reason about admin configuration)
- Sequential stages: stages are reviewed in ascending sequence; the next stage only opens once the previous one completes (cross-stage parallel approval is not supported).
- Within a stage:
any: any qualified reviewer approving completes the stage.all(joint sign-off): at request creation every qualified reviewer for that stage gets their own line (bound to them personally); all must approve to advance, and any rejection fails the whole request.
- Optional stages (
is_required=False): if no qualified reviewer resolves at creation time, the stage isskippedso the flow never deadlocks. A required stage with no resolvable reviewer instead waits there until an administrator fixes the configuration (blocking is preferred over letting it through). - Multiple workflows per table: a table may have several active workflows, evaluated by sequence priority (e.g. amount tiers); the first fully matching one wins. A record can only have one pending request at a time.
- When reviewers are resolved:
anystages resolve at review time (role changes take effect instantly); joint sign-off stages bind reviewers at creation time. - API bypass (
allow_api_bypass): if the administrator enables it, requests without a user identity (API key / background jobs) pass through unguarded and an audit log entry is written. Requests carrying a user identity are never bypassed.
23.7 REST Endpoints
src/approval.ts wraps the endpoints below. If you need to call them directly (e.g. an AI Agent hitting the API), all require Authorization: Bearer {JWT}:
| Operation | Method | Endpoint |
|---|---|---|
| My pending approvals | GET | /api/v1/approvals/my-pending |
| Record approval status | GET | /api/v1/approvals/record/{res_model}/{res_id} |
| Approve | POST | /api/v1/approvals/{request_line_id}/approve |
| Reject | POST | /api/v1/approvals/{request_line_id}/reject |
| Cancel request | POST | /api/v1/approvals/{request_id}/cancel |
| Retry an approved callback | POST | /api/v1/approvals/{request_id}/retry |
| List requests | GET | /api/v1/approvals/requests |
| Workflow CRUD | GET/POST/PATCH/DELETE | /api/v1/approvals/workflows[/{id}] |
23.8 Limits and FAQ
| Issue | Explanation / Fix |
|---|---|
approve() returns 403 | You are not a qualified reviewer for that stage; make sure request_line_id came from myPending() (that list is already filtered by eligibility) |
| Approval SDK throws in an External App | Approvals are Internal-App only; External Apps have no approval endpoints |
ctx.approval raises PermissionError (no user identity) | The invocation carried no platform user (e.g. a scheduled trigger); approval actions must be user-triggered |
| Operation "succeeded" but data did not change | Check whether the response carries approval_status: "pending", or whether the exception was an approval intercept — pending is neither success nor failure |
| Approved, but the original operation never happened | Check callback_error; a failed callback does not affect the approval result and can be retried from the main app's approval panel |
| Want to configure workflows from inside the App | Not exposed; workflows are configured by tenant administrators in the main app |
| No notifications / reminders | Approvals are pull-only today (the "my pending" list); the platform sends no push. Implement reminders yourself with ctx.messaging |
| No delegation / added reviewers / proxies | Not supported yet (nor timeout escalation or cross-stage parallel approval) |
24. Enterprise Knowledge Retrieval (ctx.knowledge)
ctx.knowledge lets an action search the enterprise Knowledge Center — the per-tenant vector store built from files marked as knowledge under the platform's Knowledge Center (/files).
Core semantics (read all three before writing code)
- Retrieval only, no generation. Vector search returns source chunks with relevance scores and invokes no LLM and consumes no platform AI credit. To produce an answer, call a model from inside the action with your own key (
OPENAI_API_KEYetc. in App Secrets), usingresults[].contentas RAG context — generation cost and model choice stay entirely yours. - Retrieval uses a platform key; this is unrelated to BYOK. The tenant vector store lives under the platform's OpenAI organization, so your BYOK key cannot reach it. The platform performs retrieval on your behalf, restricted to searching your own tenant's knowledge.
- This is a separate system from the retiring "per-app knowledge base /
trigger_ai_replysupport bot" — zero shared dependencies. Do not conflate them.
API
def execute(ctx):
hits = ctx.knowledge.search(ctx.params["question"], top_k=5, score_threshold=0.5)
# enabled=False means this tenant has not marked any file as knowledge
if not hits["enabled"]:
return ctx.response.json({"answer": "No enterprise knowledge configured"})
# results are chunk-level: one file may appear several times, each with a score
rag = "\n\n".join(f'[{r["filename"]}] {r["content"]}' for r in hits["results"])
# When you need full context rather than snippets, fetch by the returned file_id
# full = ctx.knowledge.get_content(hits["results"][0]["file_id"])
ctx.response.json({"context": rag})
Return shapes
| Method | Returns |
|---|---|
search | {"enabled": bool, "results": [{"file_id", "filename", "score", "content"}]} |
get_content | {"file_id", "filename", "mime_type", "content", "truncated"} |
file_id is the platform FileNode id (the underlying provider file id is never exposed) and can be passed straight to get_content. truncated=True means the content exceeded the size cap or the source has further pages.
Limits
| Item | Value |
|---|---|
top_k | Max 20 |
| Query length | 4,000 characters |
| Single chunk returned | 4,000 characters |
get_content full text | 200,000 characters (flagged truncated beyond that) |
File-level ACL applies automatically
Results are filtered by caller identity, and restricted files behave as if they do not exist (no snippets, no filenames):
| Calling context | Can retrieve |
|---|---|
| Internal app with user context | Filtered by the triggering user's roles (matching what they see in /files) |
| External / Self-Built app | Only files at the "external-visible" level |
| Scheduled runs with no user context | Treated as a member with no roles (company-wide plus external levels) |
Deleted files and files from other tenants are excluded unconditionally. The source of truth is the platform database, so role changes take effect immediately.
Error handling
- Tenant has no knowledge base yet →
searchreturns{"enabled": False, "results": []}(not an error) get_contenton a non-knowledge or unauthorized file → raises; catch it yourself- Platform retrieval key unset → raises
RuntimeError(a deployment problem, not a fault in your code)
Scope
Requires knowledge.read, a high-risk scope — granting it requires an on-the-spot owner password step-up (Chapter 26).
25. External Service Gateway (Egress)
A Custom App cannot reach the internet freely. Every outbound call must go through an authorized external service (EgressService). Inventory the external services your App needs at the planning stage — discovering mid-development that you need outbound access breaks the flow.
Authorization model: domain-only allowlist
An external service = a slug + a base_url in a domain-only allowlist. Authorize api.example.com and the app can only call paths under that domain; unauthorized domains are unreachable regardless of what the code says.
There is exactly one configuration entry point: the "External Services" tab in the Builder (/builder/{app_id}) (the old /dashboard/settings/integrations entry has been removed). Creating an external service requires being the App's owner or holding system.admin; a newly created service is authorized to the current App by default.
Authentication: keys are the App's own responsibility
The gateway validates domains only — it does not inject or strip the Authorization header and does not custody any keys. The write APIs also reject credential fields: auth_type ≠ none or a non-empty connection_config returns 400.
Keys are managed by the App itself: store them in ctx.secrets and assemble the header yourself inside the action:
def execute(ctx):
resp = ctx.http.call("logistics", "/track",
method="POST",
headers={"Authorization": f"Bearer {ctx.secrets.get('MY_KEY')}"},
body={"no": ctx.params["tracking_no"]})
# Note: call does not raise — always check status
if resp["status"] != 200:
d = resp["data"]
return ctx.response.json({"error": d.get("fix") or d.get("error")})
ctx.response.json(resp["data"])
A raw
import httpx/import requestscall to the internet will always time out — the runner is default-deny egress, andctx.http.callis the only exit. If you see "outbound request timed out", first check whether you bypassed the gateway.
The two most common mistakes
- The third positional argument is
method(a string), not the body. Pass bodies with the keyword argumentbody=. calldoes not raise; it returns{status, headers, data}. On failure,datacarries:
| Field | Contents |
|---|---|
error_type | Classification (unauthorized / timeout / domain not allowlisted…) |
error | Technical message |
fix | A single sentence you can relay to the user as a remedy |
fix_url / required_role / retryable | Supplementary information |
fix exists by design — it lets an app show "how to fix this" to the operator rather than a technical stack trace. The Builder UI and the AI development assistant also read it when helping you debug.
Error reference
| Symptom | Meaning | Remedy |
|---|---|---|
egress_service_not_found | No external service exists with that slug | Create it in the "External Services" tab, or use the correct slug |
egress_not_authorized | The service exists but is not authorized to this App | Ask someone with the permission to authorize it in the "External Services" tab |
| The external API returns 401 | The external API rejected the credentials you supplied — an app-side problem | Check your self-assembled Authorization header and the key stored in ctx.secrets; no change to the external service configuration is needed |
Scope
Requires http, a high-risk scope.
26. The Scope Authorization Model
Every ctx method maps to a scope (a capability group). An app declares the scopes it needs; a tenant administrator approves them before they take effect.
Two layers that cannot substitute for each other
| Layer | Governs | Decided by |
|---|---|---|
| Scope | Which capability groups (read data / write data / trigger ERP / read secrets…) | App declares → administrator approves |
| Fine-grained boundary | Which specific resources | ERP tables via Data References; external services via the domain allowlist; secrets via the available key list |
For modules with no fine-grained layer (erp.*, messaging.*), scope is the only boundary.
Risk tiers
| Tier | Scopes | Approval |
|---|---|---|
| Low risk | db.read, data_table.read, messaging.read, approval.read, crypto | Administrator approval |
| High risk | db.write, data_table.write, erp.*, approval.decide, approval.cancel, knowledge.read, messaging.write, messaging.channel, http, mcp, secret.read | Administrator approval plus an owner password step-up |
knowledge.read is read-only and already has file-level ACL as a second layer, yet is still classed high-risk — the Knowledge Center may hold HR or finance documents, so the conservative value is used.
Declaration and release
- A code scan backfills
requested_scopesat release time — you do not maintain the list by hand - Publishing freezes a scope snapshot: changing scope settings during development does not affect the live version until you republish
- External and Self-Built apps require explicit administrator approval and do not use the simplified internal flow
Runtime behavior
- An unapproved scope → the call returns 403
- Every
ctxcall is audited (including the scope and the decision) - Every scope grant change records the acting identity and is queryable in the platform audit log
Do not treat scope as a UI toggle. It is a hard backend gate: a hidden front-end button, or your own permission check inside an action, cannot substitute for it — and conversely, passing scope does not mean the end user is authorized. That is what
ctx.user_permissionsis for.
27. Creating and Deleting Apps via API
Beyond manual creation in the Builder UI, Apps can also be created and deleted entirely through the API — well suited to an AI Agent's automated opening move.
27.1 Create an App
POST /api/v1/builder/apps
Authorization: Bearer {JWT}
Content-Type: application/json
{
"name": "Order Board",
"template_slug": "starter-internal",
"subdomain": "order-board"
}
- Requires the
builder.accesspermission. - On success returns 201; the
idin the response is theapp_idused by all subsequent Builder APIs.
| Field | Required | Description |
|---|---|---|
name | Yes | App name, 1–100 characters |
template_slug | Yes | Template slug — creation is template-driven; you cannot create a fully blank App |
subdomain / url_name | No | Custom subdomain / URL name |
27.2 Starter Template Reference
| template_slug | access_mode | Suited for |
|---|---|---|
starter-internal | internal | Internal tools used by tenant members |
starter-external | external | Outward-facing apps with self-service end-user registration |
access_modeis determined by the template and cannot be changed after creation — decide up front whether the App faces inward or outward.- The App's slug is generated by the system and cannot be specified.
- Other templates from the template marketplace can also be used by their slug (list via
GET /api/v1/templates).
27.3 First Things After Creation
- The starters are not empty: they seed roughly 23 files, including demo actions. Clear out any demo content irrelevant to your needs before you start building (see Chapter 5 for VFS operations).
- Keys are not collected at creation time: configure them in the Builder's "Services" tab after creation.
27.4 Deletion and Duplication
DELETE /api/v1/builder/apps/{app_id}
⚠️ There is no two-step confirmation — one call deletes the App. In automation scripts, place the delete behind an explicit human confirmation.
POST /api/v1/builder/apps/{app_id}/duplicate
Duplicates an existing App (VFS included) — useful for the conservative "copy first, then experiment" workflow.
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