A Xuda app is a set of typed documents, and one interpreter runs them.
Studio does not generate a codebase for you to maintain. A project is a flat set of documents, every one of them stamped docType studio, in twelve node types: components, tables, data units, routes, agents and the rest. The tree in the left panel is derived from those documents at load time, from one parent pointer on each, and is never stored as a tree of its own. The same documents are what a preview asks for by id, what a build walks, and what the runtime executes when someone opens the app. Nothing in between compiles them into a different thing.

Node types a project is built from
Event types a component can react to
Deployment types the same project can ship as
First party plugins, across six categories
Five separate trees, and no tree document anywhere.
The left panel does not hold one tree. It holds five, and a five way icon segment above it decides which one is mounted, so tables, screens, shared state, URLs and agents are five separate address spaces rather than five branches of the same one. None of the five is stored as a tree either. Every node is a document carrying a single parentId pointer and an order_ts sort key, and a web worker rebuilds the whole structure on load by scanning for children level by level, admitting only documents whose stat is 3. Moving a node up or down is not a file move: it saves exactly two documents with their order_ts values swapped. Deleting a subtree writes stat 4 onto every document in it and leaves them all in storage, which is why the tree stops showing them and the search index stops indexing them at the same moment. Open a component and switch to its UI tab and a second tree appears below the first, the element tree of that one component, with a draggable handle between them whose position is remembered.
Four tabs is true of a component. Every other type opens something else.
The version of this page you may have read said that screens, background jobs and API endpoints are the same kind of object with the same four tabs. The first half is true, the second is not. The editor builds its tab strip by switching on the node type, and the envelope shrinks and grows: a component gets four tabs, a batch or a data unit gets three and has no UI at all, an API unit gets four but the fourth is Output rather than UI, and five of the twelve types open a single tab or none: alert, javascript, route and ai_agent each open one editor, and a folder opens nothing at all. The strip is only rendered when a node has more than one tab, so those five arrive with no strip above them. Which tab is open is a query parameter on the editor URL, and the last tab you had on a given node is remembered per node, so coming back to it puts you where you left. The second column is the other half of the same grammar. The Create panel offers a different set of types depending on which node you right clicked, and a folder simply inherits the set of the section it sits in.
What it opens | Where it can be created | |
|---|---|---|
| Component | Datasource, Fields, Events, UI | Under Programs, or inside another component |
| API | Datasource, Fields, Events, Output | Under Programs or any other unit, never inside a component |
| Batch | Datasource, Fields, Events. No UI tab | Programs, inside a component, or inside any other unit |
| Get data (get_data) | Datasource, Fields, Events. No UI tab | Programs, inside a component, or inside any other unit |
| Set data (set_data) | Datasource, Fields, Events. No UI tab | Programs, inside a component, or inside any other unit |
| Alert | One tab, Alert | Under Programs or inside a component, nowhere else |
| JavaScript | One tab, Editor | Under Programs or inside a component, nowhere else |
| Table | Data, Fields, Indexes | Under Database, where Create then greys out on the table itself |
| Globals | Fields, Events | Not from the tree: the Globals root refuses new children |
| Route | One tab, Route | Under Routes, the only type that section offers |
| AI agent (ai_agent) | One tab, AI Agent | Under AI Agents, the only type that section offers |
| Folder | Nothing at all. Opening a folder returns early | Under Programs or Database, or inside a component or any other unit |
The assistant writes into the same tree you edit by hand, and one mode writes nothing at all.
Describe what you want in the AI panel of an open project and the builder proposes tables, components, fields and events as ordinary documents in the ordinary tree, which is why you can open the result afterwards and change one field. The two switches on the panel are not the same switch: scope decides what the conversation is about, mode decides whether the turn is allowed to write. Inside Studio the composer also hides the model picker and the agent picker that the rest of the dashboard shows, so there is no model to choose and no agent to choose.
- Plan mode writes nothing. The panel describes Auto as running end to end without interruptions and Plan as researching and proposing an approach without making changes. The choice rides on the request as plan_mode, and the server turns it into a different method with saving switched off for the whole turn.
- Scope decides what the turn is bound to. In general scope the conversation references the app; in node scope it references the node you have open, as a studio_node rather than as an app. Switching scope starts a new chat and reloads the chat list, because past chats are fetched by what they reference.
- The context chips are toggles, not labels. Up to three appear: the program open in the editor, a different node selected in the tree, and the UI element selected while the UI tab is open. Click one off and it is dropped from the request. Switch all three off and the turn is sent with no context at all.
- The builder is one npm package with named methods, and two of those methods write plugins. app_builder saves studio documents, app_planner does not, and create_plugin_via_ai and modify_plugin_via_ai are exported from the same package. What that package does inside itself is not in the platform source, so this page describes the call site and stops there.
- You describe itIn the AI panel of an open project, in general scope or bound to one node
- The chips bind the contextOpen program, a node selected elsewhere in the tree, the selected UI element
- The last 12 messages ride alongEach one truncated to 2000 characters before it is sent
- app_builder saves, app_planner does notAccepting a plan re-sends it with an instruction to execute it end to end
A build turn hands back a receipt naming what it changed.
The reply is not the model narrating what it did. A formatter in the platform reads the structured result and writes the message: it counts the documents, groups them by their menu type, lists up to twelve of them by name with the document id in brackets, and states whether they were saved. A plan turn renders different fields entirely, an overview, up to twelve proposed units, eight build-order steps and eight acceptance checks, and always states that it kept the turn in planning mode. When the assistant is blocked on something it needs from you it does not ask in prose either: it emits a structured question block that the panel renders as a stepper, two or three options per question, with a free-text Other row the interface appends itself. While the turn runs, the status text rotates through a fixed list of three messages, one list per mode, changing every 1800 milliseconds.
Two engines sit behind one segmented control.
On a table datasource the filter mode is Index or Query, and when the setting is absent the runtime infers Index if an index is selected and Query if none is. These are not two skins over one planner. Index mode reads a compound key the table already wrote onto every record. Query mode reads a selector assembled at request time. What you can express, and what you are guaranteed, changes with the position of that control.
There is no property record. Every property is an attribute.
Select an element in the tree and the properties panel fills with rows. Those rows are not writing into a private property blob attached to the node. An element is an id, a tag name, an attributes map, children and text content, and every setting in the panel is literally one attribute in the xu- namespace. Set Bind and the markup gains xu-bind. Type whatever you like into Add Attribute and it sits beside the others as an ordinary attribute. That is why a class you wrote by hand, an attribute you invented and a control in the panel all end up in the same place, and why Edit HTML shows you the whole truth about an element rather than a rendering of it.
- The panel opens with three rows, xu-show, xu-bind and xu-ref, and grows as you use the accordions below them. A further fifteen names are kept out of the free-form attribute list because each already has its own dedicated control, so no attribute is editable in two places at once.
- Show and Render are not two words for hiding, and the runtime says so in its own comment. xu-show is a display toggle and the element stays mounted. A render gate flips one subtree in or out of the document, so the element is simply absent from the DOM.
- Any attribute has a computed twin: write xu-exp: in front of the name, or press the fx button beside the field, and the value becomes an expression that recomputes when its dependencies change. It is generic rather than a curated list of bindable properties, so it works on src, on an attribute you typed a minute ago, and on fields a plugin declared. A value and its computed twin are the same slot rather than a default plus an override, so Studio flags an element carrying both and offers to remove the static one. One documented exception: xu-exp:class replaces the static class attribute instead of merging with it.
- The limit, plainly: there is no styling UI. No spacing controls, no typography controls, no colour picker, no box model editor. Styling is a row of class chips plus a raw CSS editor, so a padding value is a Tailwind or Ionic class string you type or paste, and a conditional class is a class name mapped to an expression inside xu-class.
The canvas is a layout surface. The runtime is one toggle away.
Both surfaces read the same element tree, and they do different amounts with it. The design canvas renders your markup so you can lay out and select, but it does not run your app: nested components and widgets draw as labelled placeholders, and conditions are matched as text rather than evaluated. The toolbar toggle swaps the canvas for the live preview, which is the real runtime with a Props popover for feeding test values in. Knowing which surface you are standing on explains most of the moments where a screen looks right in one place and behaves differently in the other.
A workflow is a job queue, not a list of awaits.
An event does not run its actions one after another. It walks the workflow once and pushes one job for every enabled step onto a single array held on the session, and a separate drain loop running every millisecond takes the first job that is not already marked busy and executes it. Order is array order, top to bottom. The thing that starts the next step is not the previous step finishing, it is the previous step deleting its own job. The async flag is one line inside that loop.
Twenty-four actions in nine groups, and the argument each one reads.
The Add Action picker is built from one catalogue file, and outside the AI entries every action agrees three ways: the catalogue, the handler map in the runtime, and the validator registry that records the payload key each action reads. That registry is the right hand column. Two things before the table. Invoke Action offers eleven values in its dropdown and nine of them work, because act_close and act_back are commented out of both the runtime and the validator allowlist. And seven older UI element actions are hard rejected as deprecated rather than quietly tolerated. You can also make an action without the picker: drag a node from the tree onto the workflow and the node type decides the action, so a get data node becomes Get Data and a component becomes Call Page. Drop it onto a row that already exists and it retargets that row instead of appending a new one.
The actions in the group | What the action reads | |
|---|---|---|
| Navigation, 3 | Call Page, Call Modal, Call Popover | Each one names the component to open, and that is the whole payload. |
| Database, 3 | Get Data, Set Data, Batch | The datasource unit to run. Set Data and Batch also name the field their result lands in. |
| JavaScript, 4 | Call Native JS, Call Evaluate JS, Execute Native JS, Execute Evaluate JS | The two Call actions name a JavaScript program unit in the tree. The two Execute actions carry the code itself, typed into the action. |
| Event handlers, 4 | Raise Event, Emit Event, Invoke Action, Delay | Raise Event names an event plus its parameters, each one a literal value or an expression. Invoke Action reads one of the nine allowed values. Delay reads a number of milliseconds. |
| UI utilities, 4 | Call Alert, Alert, Loader On, Loader Off | Call Alert names an alert unit. Alert carries its own display mode, type, title and body. The two loader actions read nothing. |
| API, 3 | Call Project API, Call System API, Call External API | A project API names a unit in your own tree, a system API names a method, an external API takes a URL and an HTTP method. |
| Dataset, 1 | Update | One value, written as "@field: expression; @other: expression", or as a JSON5 object. |
| Plugins, 1 | Call Library | The plugin name and the method on it, so a purchased library reads like every other action in this table. |
| Comment, 1 | Comment | Nothing. The registry describes it as documentation only, and the runtime handler does nothing but delete its own job. |
The escape hatch is a path, not a text box.
Seven places let you stop configuring and start writing, and the deepest of them is not an action at all: a JavaScript program is its own unit in the tree, with its own build output and a documented set of things handed to it. Two limits belong in the same breath. The validator does not parse a JavaScript body, so a syntax error surfaces from the build as a failed compile rather than from the checker, which only warns when the script is empty. And an inline script or style attribute on an element must not contain a quote character at all.
- A program body is handed ten named arguments: params, xu, SESSION_ID, SESSION_OBJ, jobNoP, $container, func, glb, APP_OBJ and evt. With no top level import or export it is wrapped in an async function, so top level await works.
- Write an import or an export and it becomes a real ES module instead. Relative specifiers are rewritten against the scripts folder for that program, so a unit can import its own sibling files.
- Saving a JavaScript unit fires a Vite build without blocking the save. At run time the engine prefers the built module, cache busted per build, loads a stylesheet only if the module exports one, and falls back to running the source with warning code RUN_MSG_JS_010.
- The xu object a script receives is a fifteen method API: read and write a field, invoke an event, four database calls, call another script, three kinds of API call, alert, log, and read or write the drive. Inline code is a different shape again, wrapped as an async function and called with the element and the browser event.
The preview is a peer of your editor, and it is allowed to give up on you.
A preview session opens on a channel named after your session token and asks your open tab for documents one id at a time. Studio answers out of memory with a copy of the document on your screen, so there is no bundler anywhere in that path and a saved change is in the running session immediately. There is also a deadline. If your tab has not answered within five seconds, that session switches to the built copy on the server and never asks you again for the rest of its life.
The command box is eval, running inside the page you are previewing.
There is no command vocabulary to learn, because there is no command set. What you type is sent to the preview as one string, evaluated there, and the result is sent back and rendered as a tree two levels deep. Anything the running page can see, you can see: the session object, the datasources, the runtime helpers. A throw comes back with an error flag and renders red. Inspect is the other half: switch it on, every element carrying a ui id outlines under the cursor, and clicking one sends back the component that owns it and the node inside it, with a button that opens that component on its UI tab and selects the node in the tree. That is a pixel to a node in two clicks.
A save is a local write. The version is written by something else, afterwards.
There is no autosave. Pressing save hands the document to a worker, which stamps it and writes it into a database inside your browser, and that is the whole of what your keystroke does. Everything after it is other machinery: a broadcast to your other tabs, replication out to the app database, and a server process watching that database which is the thing that actually mints a version. The order matters, because it tells you what survives a dropped network and what does not.
The pill has four states, and one of them says your tab is out of date.
The label next to the editor is derived, not stored: it compares the revision you have saved against the newest snapshot on the server. Current means an exact snapshot for your revision exists, or that the difference is only build metadata. Recording means your revision is ahead and the capture has not landed yet. Behind means your saved revision is older than the newest snapshot, so this browser session slept through revisions, and the popover says so in a banner instead of claiming you are current. Unknown means same generation, different content, which the source calls a divergent branch. Selecting an older row previews it in place with your working copy held by reference, and while a preview is up both save paths refuse the document outright. Restore is the only action that writes: it re-reads the live revision from the server first, asks before overwriting a change that arrived while you were looking, asks again before discarding your unsaved edits, then chains onto the freshest known revision so the restore lands cleanly at the head.
save, captured on the server a moment later
What is not here, in full: no branches, no compare or diff view, no merge, no commit messages. History is per program rather than per project. The version numbers are positional, so v3 today is not v3 next week. Fifty snapshots are kept per document and the rest are deleted rather than archived. The capture is asynchronous and server side, so a fresh save reads as recording until it lands. And undo is a different system that shares nothing with this one: fifty steps per document, collapsed at four hundred milliseconds so a drag or a burst of typing is one step, scoped to the node you have selected, held in browser memory only, and gone the moment you reload. After a reload, this rail is the only way back.
There is a lock. There is no merge.
The lock is a check-in, and it is per node. Saving is gated on it: a node checked in by someone else refuses with the name of whoever holds it, and a node nobody has checked in refuses with Please check-in after walking up the parent chain to see whether anything above it is held. Drafts are exempt, and so is an app that has never been saved. What is not locked is the outcome once a revision from someone else does reach a document you have open. Studio decides between exactly three, and it computes whether you have unsaved edits before it updates its baseline, which is the ordering that makes the answer trustworthy.
- 01
Your copy is clean, so you take theirs whole
Nothing of yours is at stake, so Studio fast forwards the open document to their revision wholesale and refreshes the tab flag immediately rather than waiting for the throttled watcher. You find out because the content changed under you, not because a dialog asked.
- 02
You have edits, and their write was the compiler
Studio compares the two documents with the revision list, the studio metadata, the build result, the build error, the three timestamps and the script dependencies stripped out. When what is left is identical, the arriving revision is the build worker saving its own output. Your edits stay exactly where they are, only the revision and the build fields are adopted, and you are not told, because nothing happened to you.
- 03
You have edits, and their content really differs
Every edit of yours is kept and only the revision is bumped, so your next save chains onto theirs instead of colliding. Then you are warned for twelve seconds: a collaborator saved this document, and your unsaved edits will overwrite their changes when you save. The warning has exactly one action, discard mine and use theirs, which replaces your working copy with the server document. Ignore it and save, and your version is the one that survives. There is no field level merge, no diff, no presence and no shared cursors.
One checker file, run in your browser or on the server depending on where you are.
Validation is not a service somewhere else. It is a single file, runtime/js/modules/xuda-studio-checker.mjs, 4,714 lines, imported directly by the Studio front end and imported by path on the server by the app module. Studio decides at call time which host runs it. Online, on a project that is not a draft, it fires run_programs_checker as a background job. Offline, or inside a draft, it runs the same check over every program in the browser and commits a result of the same shape. Findings split into errors and warnings on nothing more than a type field of E or W. Then the honest part: it does not stop a build. build_app takes ignore_errors with a default of true, no caller in the product ever sends false, and the error total is written into the release itself, so every version on record carries the number of errors it shipped with.
- Eighteen check functions dispatch across eleven program types: globals, table, component, javascript, get_data, set_data, batch, api, alert, route and ai_agent. A folder is a deliberate no-op, and anything unrecognised raises CHK_MSG_GEN_050.
- A server run writes one check document per program, keyed on that program revision, so a program nobody has touched since the last pass is skipped outright rather than rechecked.
- Every code in the generated catalog carries the same fields: title, severity, domain, category, summary, causes, resolution steps, a help slug, legacy patterns and related codes. It is generated from the error registry, not written by hand.
- The two hosts do not produce identical output. The server run is initialised with the deployment list and can mark a program as used by a deployment. The browser run is not, so the not-in-use verdict differs between them.
A build and a deployment are different objects, and a release sits between them.
Building writes a numbered snapshot. Deploying points a machine at one. The number on the badge, #37, is app_build_id, a per project counter that steps up by one every time build_app runs. The 1.4.0 next to it is a separate string you type, validated by a plain major, minor, patch pattern in the badge itself. They move independently, and both are stamped onto the deployment document so a deployment always records exactly which snapshot it is serving.
Pressing deploy does not install anything.
The deploy call is the first half of a handshake. It creates records, requests a machine and returns while the machine is still being made. The install starts when the machine itself calls the platform back, which is why the progress bar can sit still for a while in the middle and why the status ladder has a waiting rung on it at all: 1 created, 2 installing or waiting, 3 active, 4 failed or terminated.
- 01
The form returns before anything exists
deploy_app writes the deployment document, optionally a datacenter document, asks the host to create a machine asynchronously, and returns to you. Nothing has been installed and nothing has been copied. The deployment sits at status code 2 under the description farm server waiting to install, and there is genuinely nothing further to show until the next step happens on its own.
- 02
The machine calls the platform back
First boot on the new machine curls a webhook at the platform carrying the app id. That handler reopens the same job you are already watching, replaces its step list with the twenty four entry install list, and runs install_hosting_resources. The job you started for phase one is the job that reports phase two, which is why the step names change under you partway through.
- 03
The install runs, retries if it has to, and lands at status 3
The install is wrapped in four attempts with a thirty second delay, because a machine that has just been created may still be rebooting when the first SSH connection arrives. It writes the pm2 ecosystem file, runs pm2 delete and then pm2 start against it, and finishes at status code 3, server ready. This page does not quote a duration for any of it, because nothing in the source records one and we did not measure it.
Sixteen deployment types, and what each one actually runs.
The same project ships as sixteen declared deployment types in four groups: six web, five server, three desktop and two mobile. Each type also declares its own tab set off a fourteen tab base list, so a cron deployment drops launch, domain, firewall, analytics and user assist and resolves to nine tabs, while an MPA keeps all fourteen and the five artifact types insert a publish tab instead. Grouped by what the install actually writes into the pm2 ecosystem file, fifteen of them collapse into five shapes. The sixteenth, mcp, is declared alongside the rest and has no ecosystem entry of its own that we could trace.
What runs on the machine | What it launches | |
|---|---|---|
| Web: spa, mpa, pwa, embed | One pm2 app named webserver, exec mode cluster with instances set to max, plus a systemd unit for the websocket router | The build stamped runtime bundle over HTTPS on port 443, bound on a dual stack socket |
| Web: preview | The same webserver app, and a second pm2 app named preview | A Nuxt server output on port 3023 alongside the app itself |
| Web: user group | The same webserver app, and a second pm2 app named user_group | A Nuxt server output on port 3022 for the group surface |
| Server: cron, api, node, emitter | A single pm2 app in exec mode fork, running that type entry file, xuda-cron.mjs or xuda-api.mjs and so on | The server bundle for this build. A cron fire forks a child, waits for it to report ready, sends it the run, and logs what comes back |
| Artifacts: ios, android, macos, windows, linux | Nothing. The macOS path deliberately bypasses the deploy call and writes a deployment document with no hosting machine behind it | A publish tab, and a dispatch to an external build service. We traced enough to know this is a different pipeline, not enough to describe it here |
One interpreter, two sides, one call.
What a deployment actually ships is not generated source and not a compiled blob. It is an interpreter bundle plus your program tree as JSON, and the same interpreter runs on both sides of the wire. In the browser that is one script tag and one constructor. On the server it is one dynamic import of the sibling bundle and one flag. When a page is rendered server side, the server asks the worker to run a program to a string, and gets back both markup and a payload. The payload is inlined into the page, and the mount element is marked with the render mode and the client activation the server resolved for it.
The category in the package name decides where a plugin shows up.
All 62 first party plugins are named the same way, and the category in that name is also on the plugin record. That is the whole routing rule: install one and it turns up in the datasource picker, the insert toolbar, the icon picker or the workflow action list, with nothing to wire up. An install is not a feature flag either. The server writes a record onto the project carrying the version, the type and the file manifest, runs a real npm install into the plugin directory for that project, syncs that directory out to the servers, and copies the whole package file tree into the project database keyed by relative path.
Nine things Studio does not do.
- The prompt box on the Studio start screen does not build anything: it sends one plain chat request capped at 1024 tokens, and with messages hidden the reply is not even rendered, because the builder lives in the AI panel of a project that is already open.
- Choosing Template does not open a gallery inside the editor: it opens the dashboard create wizard in a second browser tab, and the project it forks is a snapshot with nothing linking it back to its source afterwards.
- A draft is not on a server: it lives in one browser profile on one device until you press Save, and clearing site data ends it.
- The check-in claim that gates saving is not a server lease: it is a field on the document, so a save that never reaches the server never clears it, and nothing merges two edits for you.
- Nothing can make a build fail on validation errors: the checker runs first and its error total is written into the release, but no caller asks the build to refuse.
- A finished build does not touch anything that is already live, and two deployments of one project can sit on different build numbers for as long as you leave them there.
- There is no staged rollout: the build badge can render a staged but not live banner, but a normal build assigns the staged and the live number the same value, so that banner cannot fire.
- There is no per app trimming of the runtime, so every deployment ships the same full interpreter bundle for its build and only the program JSON differs.
- A scheduled deployment will not run a screen, only batch, get_data and set_data programs, and a server side JavaScript step is stopped at sixty seconds.
The questions you are forming right now
Every one of these seven answers ends on a limit. They are here because you would meet all seven in week one anyway.
See Studio open a project and fill in the tree.
Open an empty project and watch what gets written.
Two documents, one route, and the editor is open. Nothing reaches a server until you save, and nothing reaches a visitor until you build, and then move a deployment onto that build on purpose.