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

FeatureInternalExternalPublic (Anonymous)
Use CaseInternal management toolsExternal customer/supplier appsProduct catalogs, venue showcases, public pages
AuthenticationPlatform account loginIndependent account systemNo login required (anonymous)
API AccessFull (via Builder API)Full (via Builder API)Read-only pub/ API (see §18)
Data WriteCRUDCRUDRead-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

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

4. VFS File Structure

Standard File Tree

├── package.json                    # Dependency declarations
├── src/
│   ├── main.tsx                    # Entry point (must exist)
│   ├── App.tsx                     # Routing + Layout
│   ├── App.css                     # Global styles
│   ├── routes.ts                   # Navigation config
│   ├── api.ts                      # SDK: 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 anotherfrom 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

FileDescription
src/api.tsData Center custom table CRUD SDK
src/db.tsDB Proxy SDK
src/action.tsServer Action SDK
src/approval.tsApproval SDK (Internal App only — see Chapter 23)
src/user.tsUser Context SDK (current user's roles/permissions, Internal App only — see Chapter 7)
src/db.jsonAuto-injected at Runtime

5. Code Injection API

API Endpoints Overview

OperationHTTP MethodEndpoint
Get App (inc. VFS)GET/api/v1/builder/apps/{slug}
Full VFS OverwritePUT/api/v1/builder/apps/{id}/source
Partial File UpdatePATCH/api/v1/builder/apps/{id}/source/files
Delete FilesDELETE/api/v1/builder/apps/{id}/source/files
PATCH /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "files": {
    "src/pages/NewPage.tsx": "import React from 'react';\n\nexport default function NewPage() {\n  return <div>New Page</div>;\n}",
    "src/App.tsx": "...updated complete content..."
  },
  "expected_version": 5
}

Delete Files

DELETE /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "paths": ["src/pages/OldPage.tsx"],
  "expected_version": 6
}

Optimistic Locking

All modification endpoints support the expected_version parameter:

  • Record vfs_version when getting the App.
  • Pass expected_version during modification.
  • If versions do not match → Returns 409 Conflict.

6. Compilation and Debugging

Compile API

POST /api/v1/compile/compile/{slug}?dev=true
Authorization: Bearer {JWT}

Success Response:

{
  "success": true,
  "html": "<!DOCTYPE html>...",
  "bundle_js": "...",
  "css": "..."
}

Failure Response:

{
  "success": false,
  "error": "✘ [ERROR] Could not resolve \"./pages/MissingPage\"..."
}

Compilation Limits

LimitValue
Max Files200
Max Single File Size1 MB
Compile Timeout30 seconds

External Modules (Provided by Runtime)

The following modules do not need to be installed; you can import them directly:

react, react-dom, lucide-react, react-router-dom, react-hot-toast

7. Built-in SDKs

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-center or /ext/data-center automatically based on window.__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 an insert() call fails, apply the same {"data": {...}} wrapper pattern. This issue is scheduled to be fixed in the next SDK version.

Handling Approval Workflow Intercepts

When administrators configure an "Approval Workflow" for specific tables (like sale_orders) in the AI GO backend, your db.insert, db.update, or db.remove calls may be intercepted by the approval engine:

  • Insert (Insert-then-flag): The record is written to the database first (to obtain the relationship ID), but business logic is not formally triggered. It goes directly into a Pending state awaiting approval.
  • Update / Delete (Pre-guard): The record is not actually updated or deleted. The system temporarily stores your payload in an approval request until it is officially approved and executed.

Intercept Response Format: When your operation requires approval, the API returns a payload containing approval_status: "pending". Frontend developers should intercept this state and provide corresponding feedback to the user, rather than simply displaying "Operation Successful".

// Example: Handling an approval intercept on insert
const result = await db.insert("sale_orders", { data: { amount_total: 5000 } });

if (result.approval_status === "pending") {
  // result.approval_message: "This operation requires approval (2 levels). Request created."
  toast.success(result.approval_message || "Approval request submitted");
} else {
  toast.success("Order created successfully");
}

Approval State Callbacks

To prevent Custom App developers from having to write Python backend code just to handle approval states, AI GO provides Generic State Callbacks.

For db.insert and db.update operations, you can configure the following three fields when creating an ApprovalWorkflow. The core engine will automatically update the record's status when a manager approves or rejects:

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

How it works:

  • When the frontend submits an insert operation, the record is directly stored in the database with a draft or pending status, and a pending approval request is generated.
  • When the final manager clicks Approve, the core engine automatically executes: UPDATE "your_table" SET "{approved_state_field}" = '{approved_state_value}' WHERE id = ?.
  • If a manager clicks Reject, it automatically executes: UPDATE "your_table" SET "{approved_state_field}" = '{rejected_state_value}' WHERE id = ?.

With this mechanism, your Custom App only needs to build the frontend interface and query conditions (e.g., fetching only documents where state === 'approved') to achieve a complete end-to-end no-code approval loop!

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).

FunctionDescription
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.admin is a master key that passes every hasPermission check. This matches the main app's usePermissions() 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_permissions inside 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

MethodDescription
ctx.paramsParameters passed from the frontend
ctx.app_id / ctx.tenant_idUUIDs of the running app and tenant (strings)
ctx.action_nameName of the action currently executing
ctx.envRuntime environment (online / dev)
ctx.user_idTriggering user's UUID (string)
ctx.user_rolesTriggering user's role names (list[str], read-only snapshot; empty for scheduled runs with no user context. Display only)
ctx.user_permissionsTriggering 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

MethodWhat 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 readingctx.db has no structural operations — Action runtime cannot create tables or alter fields; that boundary is intentional (see Chapter 13). Further reading:Every ctx method 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 on class syntax have all been removed. This section is authoritative.

Isolation is now enforced in three layers outside the language itself:

  1. 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.
  2. 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).
  3. ctx RPC allowlist: platform data and capabilities are reachable only through ctx module 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 class syntax 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

LimitValue
Execution timeout30 seconds (configurable via manifest timeout_ms, capped at 30s); 3-second grace period before soft termination
Memory256 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

IssueSolution
White ScreenVerify that React is mounted correctly in src/main.tsx
Routing not workingUse HashRouter, do not use BrowserRouter
Page cannot scrollShadow DOM container must set height: 100vh; overflow-y: auto (See Chapter 11)
CSS not appliedAdd import "./App.css" in main.tsx
CSS variables entirely missing (after deployment)Using :root to define variables cannot penetrate the Shadow DOM. Use :host, :root instead (See Chapter 11)
db.update() returns "No valid field data"The SDK's update() does not wrap the payload with {"data": {...}} (See Chapter 7 DB Proxy Warning)
db.ts calls return 500This is a platform backend issue, not a frontend bug. Verify: 1) Data Reference is created and published 2) Table name is correct 3) Report to platform admin to check backend logs
db.ts / api.ts return 401Token may be expired or SDK variables are improperly injected. Verify SDKs are not manually modified and Runtime started correctly
409 ConflictVFS modified concurrently. GET the latest, merge, and retry
423 LockedPending publish request. Wait for approval or cancel it
Action timeoutOptimize logic to complete within 30 seconds
pub/ API returns 403Verify allow_anonymous_access=true and the table has is_public_readable=true (see §18)
pub/ API returns 429Exceeded anonymous Rate Limit (120/min per IP), try again later
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" exceptionAn 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

SelectorStandalone HTML PageShadow DOM (AI GO Runtime)
:rootYesCannot penetrate
:hostMeaninglessMatches Shadow Host
:host, :rootfallbackMatches

Self-Check Checklist

  • Global search for :root { (without :host) — Change to :host, :root {
  • Global search for html { (without :host) — Change to html, :host {
  • Ensure Dark Mode @media blocks also use :host, :root

JavaScript API Limitations

Certain native browser APIs are silently blocked (no errors thrown, no visual display) inside a Shadow DOM, leading to "preview works, deployment has no response":

APIShadow DOM BehaviorAlternative
confirm()Silently returns falseReact useState two-step confirmation
alert()Does not displayreact-hot-toast or custom Toast
prompt()Returns nullReact custom input modal
// Correct: React state confirmation
const [showConfirm, setShowConfirm] = useState(false);

// 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 HashRouter when 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"

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.py and actions/foo.py are 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):

OperationPermission required
Create/alter table, add/alter fielddatacenter.schema_write (system.admin passes through)
Drop table, drop fieldsystem.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 namePhysical name
Chosen byYouGenerated by the system from the display name
MutableYes, any timeNever, once created
Character setAnything (often Chinese)ASCII only
Used forUI renderingAPI identity, deletion confirmation value

Every API refers to existing tables and fields by physical name. Renaming the display name breaks nothing.

One exception: a relation field pointing at a custom table uses target_table_id — the target table's UUID (the id from GET /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

TypeDescriptionExtra contract
textText
numberNumeric
booleanBoolean
dateDate
datetimeTimestamp
selectSingle choiceOption set required; values are CHECK-constrained
relationRelationSee below
jsonStructured data
imageImageStores 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, with detail.dependents listing 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 tierPaid tier
Tables per tenant20200
Non-system fields per table50100
  • Exceeding a quota returns 409 (never a silent truncation).
  • A single POST /tables accepts 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 via POST /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:

  1. Impact preview: GET /tables/{key}/impact or GET /tables/{key}/fields/{field_key}/impact → record counts, per-field non-null statistics, and whether other tables depend on it through relations
  2. Confirmed execution: the confirmation value goes in the confirm query 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.

ActionEndpointContract
UploadPOST /api/v1/data-center/tables/{key}/imagesReturns a storage key plus a directly displayable URL; store the key
Get URLGET /api/v1/data-center/images/urlPass 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)

OperationMethodEndpointPermission
ListGET/api/v1/data-center/tablesbuilder.access
Read oneGET/api/v1/data-center/tables/{key}builder.access
Create tablePOST/api/v1/data-center/tablesdatacenter.schema_write
Update table (display name…)PATCH/api/v1/data-center/tables/{key}datacenter.schema_write
Drop-table impactGET/api/v1/data-center/tables/{key}/impactbuilder.access
Drop tableDELETE/api/v1/data-center/tables/{key}system.admin
Add fieldPOST/api/v1/data-center/tables/{key}/fieldsdatacenter.schema_write
Alter fieldPATCH/api/v1/data-center/tables/{key}/fields/{field_key}datacenter.schema_write
Drop-field impactGET/api/v1/data-center/tables/{key}/fields/{field_key}/impactbuilder.access
Drop fieldDELETE/api/v1/data-center/tables/{key}/fields/{field_key}system.admin
Query recordsGET/api/v1/data-center/tables/{key}/recordsbuilder.access
Insert recordPOST/api/v1/data-center/tables/{key}/recordsbuilder.access
Update recordPATCH/api/v1/data-center/tables/{key}/records/{record_id}builder.access
Delete recordDELETE/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

CallerPrefixCapabilities
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 runtimesame (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, statusno email (a privacy design).

Current approach for referencing users from custom tables:

  1. Store the user's UUID in a text field (relation fields cannot target users yet).
  2. When rendering, call GET /api/v1/users to 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 no custom_data column, and sharing one dataset across Apps is precisely their design goal, so tagging them apart is an anti-pattern (see Chapter 13).

Implementation Steps

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

    await insert("sale_orders", {
      data: {
        name: `ORDER-${Date.now()}`,
        amount_total: 1000,
        custom_data: {
          app_domain: "space",  // ← Declare data ownership
          booking_date: "2024-05-01"
        }
      }
    });
    
  2. Forced Filtering (Query): For all query actions, whether lists or relational queries, explicit JSON field filters must be added.

    const spaces = await query("product_templates", {
      filters: [{
        column: "custom_data",
        op: "ilike",
        value: "%space%"  // ← Filter data belonging only to app_domain="space"
      }],
      limit: 100
    });
    

    Note: Using ilike or advanced JSONB operators is a common workaround; native JSON structure filtering will be supported by the platform in the future.

  3. Whitelist Isolation (AppDataReference): When establishing an App's DB Proxy authorization (app_data_references table), this cannot restrict Row-Level access. Therefore, protection at the frontend code level is mandatory. Only a properly implemented frontend filter, paired with correct AppDataReference field whitelists, can achieve comprehensive data isolation.

Shared Table vs. Dedicated Table Dilemma

  • Use 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

OperationHTTP MethodEndpoint
Upload FilePOST/api/v1/ext/storage/upload
Get File URLGET/api/v1/ext/storage/url?path={path}
Delete FileDELETE/api/v1/ext/storage/file?path={path}
List FilesGET/api/v1/ext/storage/list?folder={folder}

Upload File

POST /api/v1/ext/storage/upload
Authorization: Bearer {custom_app_token}
Content-Type: multipart/form-data

file: (binary)
folder: "receipts"    # Optional, subfolder name

Response:

{
  "path": "tenant-id/app-id/receipts/invoice.pdf",
  "bucket": "files",
  "size": 102400,
  "mime_type": "application/pdf"
}

Get Signed URL

GET /api/v1/ext/storage/url?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}

Response:

{
  "url": "<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

LimitValue
Max File Size100 MB
Storage Bucketfiles (shared, path isolated)
Path Format{tenant_id}/{app_id}/{folder}/{filename}
Cross-App Access403 Forbidden

Usage in Frontend

// Upload file
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const token = (window as any).__APP_TOKEN__ || '';

async function uploadFile(file: File, folder?: string) {
  const formData = new FormData();
  formData.append('file', file);
  if (folder) formData.append('folder', folder);

  const resp = await fetch(`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/upload`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: formData,
  });
  return resp.json();
}

// Get Signed URL
async function getFileUrl(path: string) {
  const resp = await fetch(
    `${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/url?path=${encodeURIComponent(path)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const data = await resp.json();
  return data.url;
}

Note: Uploaded files count toward the tenant's storage usage metrics. Administrators can view this via "Usage Management > Storage" on the Dashboard.


16. Best Practices for Development and Deployment

To ensure maintainability and cross-environment synchronization stability for Custom Apps, please follow these safeguards and best practices during development and Continuous Integration (CI/CD):

15.1 VFS Synchronization and Integrity Validation (Environment Sync)

When building Scaffolding Scripts to push local source code to cloud Custom App endpoints, a disconnect often occurs where "local files don't catch up with the cloud":

  • Correctly isolate environment variables: If you use APIs to write quick deployment scripts, ensure your CLI tools strictly validate or separate --env local and --env cloud. Pushing code to the wrong environment keys will trigger a 500 - Failed to load template from Storage outage error on the cloud Dashboard (because the cloud sees a version bump but no actual files).
  • Atomic operations: It is highly recommended to use PATCH /api/v1/builder/apps/{id}/source/files to update all dependent VFS files in a single pass, and append a GET check before the command finishes to confirm whether the VFS File Count matches.

15.2 Defensive Coding and Disabling Placeholders (Prevent Lazy Code Replacement)

When maintaining massive React components or complex logic, human developers or AI Agent coding assistants often use lazy placeholders like # ... or // ... Original Code. In traditional projects, this might be harmless, but in the VFS Dynamic Compilation Architecture, it is fatal:

  • Destroys Compiler Context: As the esbuild compiler processes your TSX in the cloud, any omitted or incomplete code snippets will immediately cause AST parsing failures, subsequently blocking the rendering of the entire App.
  • Strict Guidelines:
    1. No matter how minor the update is, every overwrite of a single VFS file must provide 100% of the complete source code.
    2. Leaving // Code omitted here or // ... in the source code is strictly prohibited.
    3. Leverage TypeScript for strict type definitions. Once an implicit typing error occurs, debugging costs in the Runtime sandbox will be significantly higher than local development.

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 404 if the App does not exist or is unpublished.

Permission Check (Auth Required)

GET /api/v1/builder/apps/check-access/{slug}
Authorization: Bearer {JWT}

Response:

{
  "has_access": true,
  "app_name": "Kitchen Order Management",
  "reason": null
}

Access Check Logic:

Check ItemRejection Response
App exists and is publishedhas_access: false, reason: "App does not exist or is unpublished"
User belongs to same orghas_access: false, reason: "Your account does not belong to the organization hosting this application"
User role in allowed listhas_access: false, reason: "Your role is not on the allowed list for this application"

17.4 Invite Members + Direct App Entry

Administrators can generate invite links enabling new members to complete registration right on the App login page without entering the main site (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_url starts with /app-login/, the system automatically generates an App-specific invite link, allowing the invitee to finish registration directly on the App's login page. Without redirect_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:

RuleDescription
Prefix allowlistLimited to platform-known prefixes such as /app-login/, /runtime/, /dashboard/
Character setPure ASCII [A-Za-z0-9/._~-]
Forbidden charactersMust 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:

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

17.6 Error Handling

ScenarioPage Display
slug does not exist"Application not found" error page
Invitation token invalid or expired"Invitation link invalid" + "Go to Login" button
Incorrect credentials"Incorrect username or password" displayed in form (No redirect)
Login successful but no access"Cannot access this application" + Recommend contacting admin

17.7 Forgot Password

The login page incorporates a native "Forgot Password" feature. Clicking it opens a Dialog (without navigating away), which sends a password reset email after entering an Email address. After resetting, users can log in directly on the original page without losing the App slug or invitation token.

17.8 Logout

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/logout revokes the refresh token server-side (pass refresh_token to revoke that device only, or all_devices: true for 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

FieldRequired ValueDescription
status"published"App must be published
allow_anonymous_accesstrueEnable anonymous access
access_mode"external" or "self_built"External modes only

Configure via the Builder API:

PATCH /api/v1/builder/apps/{app_id}
Authorization: Bearer {JWT}
Content-Type: application/json

{
  "allow_anonymous_access": true
}

This can also be toggled in Builder UI → Publish Panel → "Allow Anonymous Access" switch.

Level 2: 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

EndpointMethodDescription
/api/v1/pub/data-center/{slug}/tablesGETList tables flagged publicly readable (including field definitions)
/api/v1/pub/data-center/{slug}/tables/{key}/recordsGETList 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: sort and filters are not accepted, page_size caps at 100, and only whitelisted fields are returned (system fields id / created_at always are).

SaaS Reference Anonymous Read-Only

EndpointMethodDescription
/api/v1/pub/proxy/{slug}/{table}GETSimple query
/api/v1/pub/proxy/{slug}/{table}/queryPOSTAdvanced query (filters / search / sort)

Example: Advanced Query

POST /api/v1/pub/proxy/aeb47f756cef/products/query
Content-Type: application/json

{
  "filters": [
    { "column": "status", "op": "eq", "value": "active" }
  ],
  "order_by": [{ "column": "name", "direction": "asc" }],
  "limit": 20,
  "offset": 0
}

Note:The limit cap for pub/proxy is 100. Values exceeding this are automatically capped to 100.

18.4 Frontend SDK Integration

The Custom App SDK (src/api.ts) has built-in automatic anonymous mode switching logic. When the Runtime detects that the user is not logged in, the SDK automatically uses the pub/ endpoints.

Automatic Switching Logic

// Internal logic in api.ts (auto-generated by Runtime, no manual modification needed)
export async function 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:

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

Detecting Login State in Pages

import React from "react";

export default function VenueListPage() {
  const isLoggedIn = !!(window as any).__APP_TOKEN__;

  return (
    <main>
      <h1>Venue List</h1>
      {/* All visitors can see venue data */}
      <VenueList />

      {/* Only show booking button for logged-in users */}
      {!isLoggedIn && (
        <p>
          Want to book a venue?
          <a href="#/login">Please log in first</a>
        </p>
      )}
    </main>
  );
}

18.5 Hybrid Mode: Anonymous + Login Switching

Custom Apps support a seamless "anonymous browsing → login → full functionality" transition:

Visitor opens App page
    ↓
Runtime detects: No Token
    ↓
Injects __APP_TOKEN__ = "", __IS_AUTHENTICATED__ = false
    ↓
SDK automatically uses pub/ API (read-only)
    ↓
Visitor clicks "Login" → Custom App Auth login page
    ↓
Login successful → Auth SDK updates window.__APP_TOKEN__
    ↓
SDK automatically switches to authenticated API (full CRUD)

Auth SDK Auto-Injection

For Apps with access_mode = "external", the Runtime automatically injects the Auth SDK, providing the following global methods:

// These methods are automatically available on the window.__auth__ object
window.__auth__.login(email, password)    // Login
window.__auth__.register(email, password, displayName)  // Register
window.__auth__.logout()                 // Logout (clears Token)
window.__auth__.getToken()               // Get current Token

Login Page Example

import React, { useState } from "react";

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleLogin = async () => {
    try {
      await (window as any).__auth__.login(email, password);
      // Login successful → Token auto-updated → Redirect to home
      window.location.hash = "#/";
      window.location.reload();
    } catch (err: any) {
      setError(err.message || "Login failed");
    }
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      {error && <p className="error">{error}</p>}
      <button type="submit">Login</button>
    </form>
  );
}

18.6 Rate Limiting

To protect anonymous endpoints from abuse, the pub/ API has dedicated rate limits:

Endpoint ScopeLimitBasis
/api/v1/pub/data-center/* + /api/v1/pub/proxy/*120 requests / minuteper IP
/api/v1/custom-app-auth/* (POST)10 requests / minuteper IP
Authenticated /api/v1/data/* + /api/v1/proxy/*600 requests / minuteper user

The anonymous Rate Limit and authenticated Rate Limit are independent of each other. Logged-in users enjoy a 600/min quota.

Response Headers:

Every pub/ API response includes:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118

When the limit is exceeded:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{
  "detail": "Too many requests, please try again later"
}

18.7 Security Mechanisms

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

18.8 Builder API Automation Setup

AI Agents or external scripts can enable Public mode in one go via the following workflow:

import httpx

BASE = "https://{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

IssueSolution
pub/ API returns 404Verify that the App is published (status = published) and the slug is correct
pub/ API returns 403Verify allow_anonymous_access = true and the corresponding table has is_public_readable = true
pub/data doesn't show certain tablesThe table may have is_public_readable = false, or the app_id doesn't match (only tables belonging to that App or shared by the tenant are shown)
Data visible on page but disappears after loginAfter login, the SDK uses the authenticated API; verify that the authenticated Data Reference is also properly configured
Cannot submit forms in anonymous modeExpected behavior — pub/ API only allows reads; form submission requires logging in
Rate Limit 429 errorAnonymous mode limits to 120 requests/min per IP; consider adding request deduplication and caching on the frontend
__IS_AUTHENTICATED__ is always falseVerify that the Auth SDK is correctly injected. For External Apps, this value being false on initial anonymous load is normal

19. External App Independent Authentication System

Use Case: External mode Custom Apps use an account system independent from the main site, allowing external users (customers, suppliers, visitors) to register, log in, and authenticate via Email + password. This mechanism is completely independent of the Internal App system described in §17 (the platform account system).

19.1 Overview

ComparisonInternal App (§17)External App (this chapter)
Account SystemPlatform account systemIndependent custom_app_users table
Token TypePlatform JWTCustom JWT (HS256)
Account SharingShared with main site DashboardIndependent per App
Social LoginNoLINE / Google / LIFF
Anonymous BrowsingNoCombined 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 RoutePageDescription
#/loginLoginPage.tsxLogin page
#/registerRegisterPage.tsxRegistration page
#/HomePage.tsxHome page (after login)

Note:App developers must create LoginPage.tsx and RegisterPage.tsx in the VFS themselves. The Runtime provides the Auth SDK (window.__auth__) to call backend APIs.

19.3 Custom App Auth API

All endpoints are prefixed with /api/v1/custom-app-auth/{app_slug}/.

Register

POST /api/v1/custom-app-auth/{slug}/register
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "mypassword123",
  "display_name": "John Doe"
}

Success Response (201):

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "rt_abc123...",
  "expires_in": 900,
  "user": {
    "id": "uuid",
    "email": "user@example.com",
    "display_name": "John Doe",
    "is_active": true,
    "created_at": "2026-06-11T08:00:00Z"
  }
}
Error CodeDescription
409Email already registered
422Parameter validation failed

Login

POST /api/v1/custom-app-auth/{slug}/login
Content-Type: application/json

{
  "email": "user@example.com",
  "password": "mypassword123"
}

Success Response (200): Same format as the registration response.

Error CodeDescription
401Incorrect email or password
403Account has been deactivated

Get Current User

GET /api/v1/custom-app-auth/{slug}/me
Authorization: Bearer {access_token}

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):

OperationMethodEndpoint
List UsersGET/api/v1/custom-app-auth/manage/{app_id}/users
Activate/DeactivatePATCH/api/v1/custom-app-auth/manage/{app_id}/users/{user_id}
Delete UserDELETE/api/v1/custom-app-auth/manage/{app_id}/users/{user_id}

List Users:

GET /api/v1/custom-app-auth/manage/{app_id}/users
Authorization: Bearer {platform_jwt}

Response:

[
  {
    "id": "uuid",
    "email": "user@example.com",
    "display_name": "John Doe",
    "is_active": true,
    "last_login_at": "2026-06-11T08:00:00Z",
    "created_at": "2026-06-01T00:00:00Z"
  }
]

Deactivate User:

PATCH /api/v1/custom-app-auth/manage/{app_id}/users/{user_id}
Authorization: Bearer {platform_jwt}
Content-Type: application/json

{
  "is_active": false
}

19.5 OAuth Social Login (LINE, Google, etc.)

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

ItemValue
Access Token Validity15 minutes
Refresh Token Validity7 days
Signing AlgorithmHS256
Token RotationOld token automatically revoked on each refresh
Multi-Device LoginIndependent session per device

Token Storage Location (managed automatically by Auth SDK):

localStorage:
  __custom_app_access_token__  → Access Token
  __custom_app_refresh_token__ → Refresh Token

The Auth SDK automatically calls the /refresh endpoint to renew the Access Token before it expires. Developers do not need to handle Token refresh logic manually.


20. Package Management and Third-Party Dependencies

Use Case: When a Custom App needs to use third-party JavaScript / TypeScript packages (such as date handling libraries, charting libraries, etc.), understanding the VFS compilation environment's package management mechanism is essential.

20.1 Runtime Built-in Modules

The following modules are globally provided by the Runtime page and do not need to be installed — just import them directly:

import React from "react";
import { createRoot } from "react-dom/client";
import { HashRouter, Routes, Route, Link } from "react-router-dom";
import { Search, Calendar, User } from "lucide-react";
import toast, { Toaster } from "react-hot-toast";
ModuleVersionDescription
react^18.xReact core
react-dom^18.xDOM rendering
react-router-dom^6.xHash routing
lucide-reactlatestIcon library
react-hot-toastlatestToast notifications

Note:These modules are marked as --external during esbuild compilation and will not be bundled into the output. The Runtime page provides the global versions.

20.2 package.json Dependency Declarations

The package.json in the VFS is used to declare the App's dependencies. However, unlike traditional Node.js projects, the VFS environment does not execute npm install. Package resolution is handled entirely by esbuild at compile time.

{
  "name": "my-custom-app",
  "private": true,
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}

package.json primarily serves as a module resolution hint for esbuild. Built-in modules (react, etc.) are pre-declared in dependencies by default.

20.3 Adding Third-Party Packages

For pure JavaScript/TypeScript packages, you can use them directly within the VFS:

Method 1: Reference directly in source code

Suitable for small utility functions. Define them directly in a component:

// src/utils/date.ts — Custom date formatting implementation
export function formatDate(date: string): string {
  const d = new Date(date);
  return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}

Method 2: Place the package source code in the VFS

Suitable for small third-party libraries. Place the minified JS directly into the VFS:

src/
├── vendor/
│   └── dayjs.min.js    ← Place the package source in the VFS
├── pages/
│   └── EventPage.tsx   ← import dayjs from '../vendor/dayjs.min'

Note: Large packages (e.g., chart.js, three.js) are not recommended for placement in the VFS, as the single file size limit is 1MB.

20.4 Limitations and Considerations

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

20.5 Common Package Compatibility

PackageCompatibleNotes
date-fns (source import)YesPure JS, tree-shakable
lodash-es (source import)YesESM version
uuidYesPure JS
chart.jsMinified version must be < 1MB
three.jsNoToo large (> 1MB)
styled-componentsNoRequires Babel transform
@mui/materialNoToo 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:

RuleDescription
Exact pins onlyOne name==version per line (extras allowed, e.g. requests[socks]==2.32.3); version ranges, URLs, and local paths are all rejected
Line capAt most 20 lines
Size capCombined wheel size ≤ 80 MiB
Target platformaarch64 / 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 in requirements.txt up front.

21. Reference System API

Prefix: /api/v1/refs Auth: Main Site JWT (requires builder.access permission)

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}
FieldTypeRequiredDescription
table_namestringRequiredName of the table to reference, e.g., "customers"
columnsstring[]OptionalList of authorized fields, e.g., ["name", "email", "phone"]
permissionsstring[]OptionalList of permissions, e.g., ["read", "create"]

Update Reference

PATCH /api/v1/refs/{ref_id}
FieldTypeDescription
columnsstring[]New list of authorized fields
permissionsstring[]New list of permissions

Delete Reference

DELETE /api/v1/refs/{ref_id}

21.4 Permission Values Explanation

PermissionCorresponding Proxy OperationDescription
readGET / POST queryQuery data
createPOST insertInsert record
updatePATCHUpdate record
deleteDELETEDelete 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:

FieldTypeDescription
idUUIDPrimary key, auto-generated
created_attimestamptzCreation time, automatically set
updated_attimestamptzUpdate time, automatically updated
tenant_idUUIDTenant 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.

FeatureDescription
TypeJSONB (PostgreSQL native JSON binary format)
Default Value'{}'::jsonb (Empty JSON object)
NullableYes (Can be set to null)
Access MethodOnly via Proxy API (Internal / External / Open)
Data IsolationFollows 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_data is not included in the referenced columns, 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.pythis 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

ItemDescription
App StatusMust be published
Execution Timeout45 seconds
Response FormatGateway immediately returns {"status": "accepted"}; Action executes asynchronously
Retry MechanismPlatform does not auto-retry; implement error logging in your Action
Signature VerificationMust 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:

TouchpointDescriptionSection
Being interceptedYour write/confirm operation matches a workflow and becomes an approval request23.2 / 23.3
Frontend SDKsrc/approval.ts: build pending lists and approve/reject buttons in your App23.4
Server Actionctx.approval.*: query and act on approvals from Python23.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 ApprovalRequest is 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

SourceControlled operationsIntercept behavior
src/db.ts (DB Proxy)insert / update / remove on authorized ERP tablesinsert = insert-then-flag; update / delete = pre-guard
ctx.db (Server Action)insert / update / remove on ERP tablesSame 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 methodsPre-guard, raises ApprovalPendingError
Main ERP endpointsSales/purchase confirmation, journal posting, picking validation, MRP confirmation, HR leave approvalHTTP 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, or ctx.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).

FunctionDescription
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:

FieldDescription
request_idRequest ID (used by cancel)
request_line_idStage line ID (used by approve / reject)
res_model / res_idTable name and record ID under approval (use these to load the source document)
workflow_name / stage_nameWorkflow name and current stage name
requester_nameRequester email; sources without a user identity show API
submitted_at / current_stage_sequenceSubmission 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>
  );
}

TipmyPending() 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.

MethodDescription
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:

  1. An App cannot sign on anyone's behalf. approve / reject must 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.
  2. Invocations without a user identity are always rejected. When a scheduled/background trigger carries no platform user, list_pending / approve / reject / cancel raise PermissionError.
  3. cancel is requester-only, and only while the request is pending.

Scope grants: ctx.approval must be granted in the App's Scope settings, at differing risk tiers —

ScopeMethodsRisk tier
approval.readlist_pending, get_record_statusLow risk (no step-up)
approval.decideapprove, rejectHigh risk (owner password step-up required when the grant is widened)
approval.cancelcancelHigh risk (same)

Tip:A non-None callback_error means 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 is skipped so 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: any stages 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}:

OperationMethodEndpoint
My pending approvalsGET/api/v1/approvals/my-pending
Record approval statusGET/api/v1/approvals/record/{res_model}/{res_id}
ApprovePOST/api/v1/approvals/{request_line_id}/approve
RejectPOST/api/v1/approvals/{request_line_id}/reject
Cancel requestPOST/api/v1/approvals/{request_id}/cancel
Retry an approved callbackPOST/api/v1/approvals/{request_id}/retry
List requestsGET/api/v1/approvals/requests
Workflow CRUDGET/POST/PATCH/DELETE/api/v1/approvals/workflows[/{id}]

23.8 Limits and FAQ

IssueExplanation / Fix
approve() returns 403You 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 AppApprovals 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 changeCheck 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 happenedCheck 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 AppNot exposed; workflows are configured by tenant administrators in the main app
No notifications / remindersApprovals are pull-only today (the "my pending" list); the platform sends no push. Implement reminders yourself with ctx.messaging
No delegation / added reviewers / proxiesNot 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)

  1. 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_KEY etc. in App Secrets), using results[].content as RAG context — generation cost and model choice stay entirely yours.
  2. 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.
  3. This is a separate system from the retiring "per-app knowledge base / trigger_ai_reply support 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

MethodReturns
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

ItemValue
top_kMax 20
Query length4,000 characters
Single chunk returned4,000 characters
get_content full text200,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 contextCan retrieve
Internal app with user contextFiltered by the triggering user's roles (matching what they see in /files)
External / Self-Built appOnly files at the "external-visible" level
Scheduled runs with no user contextTreated 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 → search returns {"enabled": False, "results": []} (not an error)
  • get_content on 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 requests call to the internet will always time out — the runner is default-deny egress, and ctx.http.call is the only exit. If you see "outbound request timed out", first check whether you bypassed the gateway.

The two most common mistakes

  1. The third positional argument is method (a string), not the body. Pass bodies with the keyword argument body=.
  2. call does not raise; it returns {status, headers, data}. On failure, data carries:
FieldContents
error_typeClassification (unauthorized / timeout / domain not allowlisted…)
errorTechnical message
fixA single sentence you can relay to the user as a remedy
fix_url / required_role / retryableSupplementary 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

SymptomMeaningRemedy
egress_service_not_foundNo external service exists with that slugCreate it in the "External Services" tab, or use the correct slug
egress_not_authorizedThe service exists but is not authorized to this AppAsk someone with the permission to authorize it in the "External Services" tab
The external API returns 401The external API rejected the credentials you supplied — an app-side problemCheck 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

LayerGovernsDecided by
ScopeWhich capability groups (read data / write data / trigger ERP / read secrets…)App declares → administrator approves
Fine-grained boundaryWhich specific resourcesERP 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

TierScopesApproval
Low riskdb.read, data_table.read, messaging.read, approval.read, cryptoAdministrator approval
High riskdb.write, data_table.write, erp.*, approval.decide, approval.cancel, knowledge.read, messaging.write, messaging.channel, http, mcp, secret.readAdministrator 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_scopes at 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 ctx call 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_permissions is 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.access permission.
  • On success returns 201; the id in the response is the app_id used by all subsequent Builder APIs.
FieldRequiredDescription
nameYesApp name, 1–100 characters
template_slugYesTemplate slug — creation is template-driven; you cannot create a fully blank App
subdomain / url_nameNoCustom subdomain / URL name

27.2 Starter Template Reference

template_slugaccess_modeSuited for
starter-internalinternalInternal tools used by tenant members
starter-externalexternalOutward-facing apps with self-service end-user registration
  • access_mode is 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