One endpoint, 1008 declarations, and 331 of them are yours.
There is no route table in this platform. http_module registers exactly two Express handlers, app.post("*") and app.get("*"), reads the third path segment of /cpi/<method>, and looks that name up in the cpi_methods object in config.json. That object holds 1008 entries. A name that is not in it comes back as {code: -1, data: 'method "X" not exist'} before any credential is read at all, in cpi/http_module/index.mjs lines 634 to 650. Adding a method to that object is the whole act of shipping an API: it is what makes the method callable over HTTP, visible as an MCP tool, plan gated, cached, audit logged and job tracked. There is no second place to register it and no generated client to regenerate. 677 of the 1008 entries carry private: true, which is the operator wall that no API key crosses in any mode, so what an external caller can actually reach is 331 methods, and the REST catalogue and the MCP tool list are compiled from that same set. The rest of this page follows one call through the seven stages it walks, shows the real transcript in both directions, and names the places where the shipped code does less than the interface around it implies.

Methods declared in one config file
Reachable with a developer API key
Clearance strings the whole surface uses
Typed field definitions, 1102 of them mandatory
Every call walks the same seven stages, and there are four ways out before your handler runs.
One method is one name, and every name takes the identical path. Public traffic lands on router_module, which terminates 443, switches on the first path segment and pipes the cpi case straight into localhost:3007. From there the order is fixed and worth memorising, because one stage sits where most readers assume it does not: the typed field contract is walked BEFORE authentication, not after it, so a request missing a mandatory argument is refused without the platform ever looking at your credential. Auth itself is a single broker call into session_module rather than middleware, the two account gates read one account document between them, and the handler is reached over RabbitMQ rather than by an import. Nothing here branches on the method name except the lookup in stage two.
The wire, both halves of it.
Three things here surprise people, so they are worth seeing rather than reading about. First, the developer key travels in the JSON body as api_key. http_module never reads an Authorization header on this path, and session_module destructures api_key straight off the request at line 116, so a client that puts the key in a header authenticates as nobody. Second, success and failure are the same object: {code, data}, with code 1 or higher for success and a negative number for everything else. Third, the HTTP status is derived from the code rather than chosen, by get_status at lines 204 to 210: only a code in the range -400 to -499 becomes its own status, so -401 is a real 401 and -428 is a real 428, while everything else, including the overwhelmingly common -1, collapses to a flat 400. A client that branches on status alone cannot tell a bad argument from a handler that blew up. The one response header exposed through CORS is xu-region, stamped with XUDA_HOSTNAME.
private is the wall, and it is the majority of the catalogue.
clearance decides who a caller has to be. private decides whether a credential other than a browser session may even attempt the method, and it is the flag that defines the shape of the product from the outside. 677 of the 1008 declared methods carry private: true. No key mode crosses that line: not a full-access key, not a custom key with the method written into its allowlist, not an MCP session. What is left is 331 methods, and that same 331 is what every external consumer sees, whether it arrives as curl or as an agent holding a tool list. The four bullets below are the mechanics of that wall, including the part of it that is a convention rather than a guarantee.
- The API key branch is entered only when the method is neither private nor owned by api_module: session_module lines 121 to 130 read if (api_key and method.microservice is not api_module and not method.private). A private method therefore never reaches key validation. It falls through to the session path instead, which a key-only caller cannot satisfy, so the refusal reads as an auth failure rather than as a 404. api_module lines 172 to 181 enforce the same rule a second time with the literal message api method <name> is unauthorized.
- All 8 key-management methods are themselves private: create_account_api_key, update_account_api_key, get_account_api_keys, create_app_api_key, update_app_api_key, get_app_api_keys, get_account_system_api_methods and get_app_system_api_methods. A key can therefore never mint a key, rotate a key, or enumerate the keys on the account it belongs to. Key lifecycle is a dashboard-session action only, and that is a property of the catalogue rather than a rule written anywhere.
- mcp_module skips private when it registers tools, so REST and MCP are compiled from the identical set. A full-access key enumerates Object.keys(cpi_methods), drops the private ones, and registers the remaining 331 as tools with a zod input schema derived from each method's declared fields. The list is resolved fresh per session rather than baked into the key, so a method shipped after the key was issued is picked up with no key edit.
- The honest edge: private is absent rather than false on 321 of the entries, and only 10 declare private: false explicitly. The wall is defined by falsiness, which means a newly shipped method with no private field is externally callable by default. That is a default, not a decision, and it is the one thing on this page worth checking against your own expectations before you rely on the 331.
Five clearance strings, and what each one actually asks you for.
One string on the config entry decides which credential a caller has to present, and the dispatcher never branches on the method name to work it out: http_module hands method.clearance to session_module.validate_cpi_access and lets that single function answer. The counts and the success codes below are read out of session_module lines 115 to 291, not paraphrased from a doc. The row worth slowing down on is admin. clearance admin is an app role, not a platform role: it resolves from a row in the xuda_team user_active_shares view, so a team admin on one project satisfies any of the 252 admin methods on that project and nothing beyond it. Platform superuser is a separate concept that is not a clearance value at all. It is a config array with exactly one entry, checked as a bypass that returns code 104 and forces auth_clearance to admin, which means it can never express more than admin. Any method that has to be staff only therefore checks for itself in its own body, and 83 sites across 25 module files do exactly that by returning {code:-403, data:'superuser only'}. That is a convention rather than a mechanism: nothing in the dispatcher verifies that a staff-only method actually carries its check.
Methods | What the caller must present | What the gate returns | |
|---|---|---|---|
| public | 51 | nothing at all, the check returns before any credential is read | code 100 |
| user | 680 | a live session doc in xuda_sessions, no app_id | code 106 |
| user_access | 5 | app_id plus a team row of user_group, developer or admin | code 6 |
| developer | 15 | app_id plus developer or admin | code 6 |
| admin | 252 | app_id plus admin, or being the app owner | code 6, owner match 106 |
| superuser | not a clearance | a uid in the 1-entry superuser_account_ids array | code 104, forced to admin |
| empty or missing | 5 | nothing satisfies it: a falsy clearance throws before every other branch | code -401. UNVERIFIED whether these five are broker-only entries |
Two front doors, one gate.
mcp_module runs its own Express app on port 3012 and speaks Streamable HTTP on /mcp, which makes it look like a second permission model. It is not one. Every tool handler it registers does a plain fetch to http://localhost:3007/cpi/<method> with the api_key moved out of the Authorization header and into the JSON body, so clearance is checked exactly once, in the same validate_cpi_access call, whether the caller is curl or an agent. The five rows below are paired on purpose: read across and you can see precisely which half of the story changes between the two doors, and how little of it does.
The ranking inverts: the biggest modules expose the least.
Thirty five modules own the 1008 declared methods, counted off the microservice field on each config entry. Sorted by how much a module declares, the list reads like a map of the platform: app_module 110, ai_module 91, account_module 90, drive_module 77, stripe_module 75. Sorted by what an external key can actually reach, that order turns over, because the second column is just the first minus everything carrying private:true. Seven modules expose nothing at all, and they are not the small ones: stripe_module declares 75 methods and publishes none, marketplace_module 25 and none, shipping_module 26 and none, commerce_module 21 and none, and bot_protection_module and jobs_module join them. The money surface and the storefront surface are entirely internal, reachable only from a logged-in dashboard session with the right clearance, which is a deliberate shape rather than a documentation gap. The last row is the sharpest of them: all eight api_module key-management methods are private, so a key can never mint, rotate or even enumerate keys. Read the zeroes first, then read the top of the table. One row deserves a second look rather than a headline: voice_module shows 46 of 46 externally callable, the only module in the platform with no private method at all, and that is worth sanity checking against intent.
Declared | Reachable with a key | |
|---|---|---|
| Drive | 77 | 66 |
| App | 110 | 61 |
| Voice | 46 | 46 |
| Account | 90 | 32 |
| AI | 91 | 26 |
| Team | 50 | 14 |
| Stripe | 75 | 0 |
| MarketPlace | 25 | 0 |
| Shipping | 26 | 0 |
| Commerce | 21 | 0 |
| API keys | 8 | 0, a key can never manage keys |
Three moves from nothing to a working call.
The dashboard does not hand you a getting-started page, it hands you four paste-and-run tabs in the API keys screen: MCP, curl, node and php, with MCP as the default and your own first key already substituted into the snippet. Worth saying plainly before you start: there is no REST SDK. The node tab is a literal fetch call and the php tab is a literal curl_init block, so what you are integrating against is an HTTP endpoint and a JSON envelope, not a client library with types.
- 01
Mint a key
Two scopes, account or app, and two access modes. A full-access key is checked only against private, and that check runs freshly on every call rather than being baked in at issue time, so methods shipped after the key was made are covered without touching it. A custom key carries an api_allow_methods list instead, with one exception worth knowing: methods at clearance public bypass the allowlist entirely, so the 51 anonymous methods are reachable on any valid key.
- 02
Ask the catalogue what you may call
get_account_system_api_methods hands back all 331 non-private methods as {description, fields, category}, where category is the owning microservice. That is the same data the MCP tool schemas are compiled from, so the REST catalogue and the agent tool list can never drift apart. get_app_system_api_methods is the app-scoped view and returns 86, the non-private admin set. Both are themselves private, so you read them from a dashboard session.
- 03
Call it, or hand the same key to an agent
POST /cpi/<method> with api_key in the body, or run claude mcp add against /mcp with the key as a bearer token. Same gate, same 331 methods, two envelopes. A key you issue for your backend and a key you issue for an agent differ in nothing except which door you point it at, which is why narrowing what an agent can do means issuing a narrower key rather than configuring the agent.
A long call answers immediately and finishes over a socket.
The thing most integrators get backwards: the job path is the default. http_module runs it unless the method entry says job:false, and 573 of the 1008 entries opt out that way, which leaves 435 that stay in. Those 435 are the deploys, the builds, the reinstalls, the work that was never going to finish inside one request. A job is registered before dispatch either way, so the question is only whether you asked to be handed the ticket instead of the result. One limit in the same breath: some methods finalize their own job, so the step counter is advisory rather than a guarantee.
- Send job:true in the body and the call returns a ticket carrying the job id while the real result lands later over socket.io on port 4010. Two evidence passes disagree on the numeric code in that ticket, one reading 88 and one reading 999 with info "job successfully scheduled to run immediately", so treat the number as UNVERIFIED and read the job id.
- job_steps is the literal progress-bar text, not an internal state machine. 38 methods declare one: build_app declares 7, reinstall_spa_resources declares 18, from initiating process through to activating the server. A job with no declared steps is given a total of 2.
- job_title is a lodash template rendered against your own request body, which is why the toast reads Reinstalling SPA Server rather than a method name. 36 methods declare one.
- broadcast:true on 194 methods is what makes the update non-silent; everything else is tracked quietly. job_timeout caps the proxied wait and defaults to 1800 seconds, and 7 methods override it.
- register_joba job doc in memcached, 600 second TTL
- You get a ticket, not a resultonly when the caller asked for it by sending job:true
- The worker reports progressupdate_job walks the declared job_steps
- socket.io, port 4010into the room your browser joined; stat 3 on success, 4 on failure
One declaration, seven consumers.
The keys on a cpi_methods entry are not documentation. Each one is read by a different subsystem at a different moment, and none of those subsystems knows about the others. clearance is read by the session gate. private is read by the API key validator and, separately, by the MCP tool registrar. fields is walked by the checker that runs before authentication and compiled into a zod schema when an agent opens a session. job is read by the queue, cache by memcached and by the edge header, min_plan by the plan gate, security by the log writer. Adding a method to config.json decides all seven at once, which is why there is no route table, no permission file and no separate schema to keep in step.
Connecting an agent is one line.
This is the whole integration surface. The MCP endpoint is a Streamable HTTP server on port 3012 proxied at /mcp, and it is the one place in the platform where an API key travels as a header: every other path, including the loopback call each tool makes back into /cpi, reads api_key out of the JSON body. Two facts decide whether this works at your scale. The tool list is resolved per session rather than baked into the key, so a full-access key picks up methods shipped after it was issued with no key edit and no redeploy. And that same key hands a model all 331 tools in one list, with no grouping and no pagination, which is past what most models select well over. The only way to narrow it is to issue a narrower key, because a custom key registers exactly its api_allow_methods and nothing else.
Nine things this API does not do, including three the dashboard implies it does.
- A key has four states in the interface, 1 draft, 2 suspended, 3 active and 4 delete, and only state 4 is rejected. A draft or a suspended key still authenticates and still executes, on REST and on MCP alike. The check lives in cpi/api_module/index.mjs lines 131 to 199.
- The domain allowlist is stored on the key and editable in the interface, but every branch that would enforce it is commented out. Do not design around it.
- There is no rate limit on /cpi. The three limiters in router_module are mounted on /login and four form routes, and the cpi case pipes straight through to port 3007 with no middleware in front of it. Public methods are protected by the bot-protection product, not by the dispatcher.
- Negative codes collapse. Only the band from -400 to -499 maps onto its own HTTP status, so -401 and -403 arrive as 401 and 403 and everything else, including the very common -1, is a flat 400. Read code and data, never the status alone.
- private is falsiness rather than a declaration. 321 entries omit the key entirely, so a newly shipped method is key-callable unless someone remembers to mark it.
- Five entries carry no usable clearance, three an empty string and two nothing at all, and the gate throws on a falsy clearance. They should hard-fail over HTTP. Whether they are broker-only internals that never see a request is unverified.
- The app-scoped catalogue filters on clearance values studio and user_group that no method carries, so it returns the 86 admin methods and silently omits the 5 user_access ones, which include dbs_read and get_app_info.
- MCP Server appears as a deploy type in the wizard and in the docs, and deploy_module refuses it with code -2 and the message deploy_type in beta. The shipping MCP surface is the one platform endpoint keyed by an API key, not a per-project deployment.
- Every count on this page was read from the repo copy of config.json. config_dev.json shows 1026 declared and 334 non-private, and the live master config was not read, so treat these numbers as the shape rather than as today's exact total. voice_module reading 46 of 46 non-private is worth confirming against intent.
The questions you are forming right now
Six things the reference will not tell you, answered against the shipped code rather than against the intent.
Watch a call go out and come back.
Read the catalogue before you write a line against it.
One call, get_account_system_api_methods, hands back every method you may reach, each reduced to its description, its typed fields and the microservice that owns it. That is the same declaration the MCP tool schemas are compiled from, so the list an agent is handed and the list you code against come out of one file rather than out of two documents that drift. Read it first, decide which methods you actually named, then issue a custom key holding only those. A full-access key is the fastest way to start and the widest thing you can hand a model.