Backend Development
Backends are isolated child processes supervised by the Core. They run on the Bun runtime. You define handlers as plain functions via the global serve() API — the runtime handles process lifecycle, IPC, and crash recovery.
The serve() API
Every worker calls the global serve() function. Each key in the object becomes an RPC method callable via POST /api/v1/apps/{appId}/rpc:
serve({
greet: async (params, caller) => {
return { message: `Hello, ${params.name || "world"}!` };
},
pipeline: async () => {
const res = await fetch(
`${process.env.ROOTCX_RUNTIME_URL}/api/v1/apps/sales_crm/collections/deals`
);
const deals = await res.json();
const stages = ["lead", "qualified", "proposal", "won", "lost"];
return Object.fromEntries(
stages.map(s => [s, deals.filter((d: any) => d.stage === s).length])
);
},
});
Handlers receive two arguments:
params— the request payload from theparamsfield in the RPC request body.caller— the authenticated user, ornullfor system calls. ContainsuserId,username, andauthToken.
RPC calls have a 30-second timeout. For long-running work, enqueue a background job instead.
Lifecycle hooks
Pass an options object as the second argument to serve():
| Hook | Signature | When it fires |
|---|---|---|
onStart |
(config) => void |
Once, after connection. config contains appId, runtimeUrl, databaseUrl, credentials. |
onJob |
(payload) => any |
A background job is dispatched. Return a result or throw. |
onError |
(error, method) => void |
A handler or job threw an error. |
onShutdown |
() => void |
Process is about to exit (redeploy, stop). |
serve({ /* handlers */ }, {
onStart: (config) => {
log.info(`Worker connected: ${config.appId}`);
log.info(`Database: ${config.databaseUrl}`);
},
onError: (error, method) => {
log.error(`Error in ${method}: ${error.message}`);
},
onShutdown: () => {
log.info("Shutting down, cleaning up...");
},
});
The onStart config object provides direct access to the PostgreSQL connection string via config.databaseUrl and app secrets via config.credentials.
Error handling
Throw to return an error. The Core translates it to an HTTP 500 with { "error": "message" }:
serve({
closeDeal: async (params, caller) => {
if (!caller) throw new Error("Authentication required");
if (!params.dealId) throw new Error("Missing dealId");
// ...
},
});
For structured error handling across all methods, use the onError hook to log or report errors centrally.
Checking permissions
Use caller.authToken to call the Core's permission API from your handler:
serve({
updateDeal: async (params, caller) => {
if (!caller) throw new Error("Authentication required");
const res = await fetch(
`${process.env.ROOTCX_RUNTIME_URL}/api/v1/apps/sales_crm/permissions`,
{ headers: { Authorization: `Bearer ${caller.authToken}` } }
);
const { permissions } = await res.json();
if (!permissions.includes("deals.update"))
throw new Error("Permission denied");
// proceed with update...
},
});
Globals
Two helpers are available in every worker without imports:
log.info()/log.warn()/log.error()— appear in the Studio Real-time Logs panel and the SSE logs endpoint (GET /api/v1/apps/{appId}/logs).emit(name, data)— fire a custom event.
serve({
processOrder: async (params) => {
log.info(`Processing order ${params.orderId}`);
// ...
emit("order_processed", { orderId: params.orderId });
return { success: true };
},
});
Entry point resolution
The Core looks for the worker entry point in this order:
index.tsindex.jsmain.tsmain.jssrc/index.tssrc/index.js
Environment variables
Every worker receives two environment variables automatically:
| Variable | Description |
|---|---|
ROOTCX_APP_ID |
The application identifier. |
ROOTCX_RUNTIME_URL |
Base URL of the Core HTTP API (e.g. http://127.0.0.1:9100). |
The databaseUrl (direct PostgreSQL connection string) and app secrets are available in the onStart hook's config object.
Process lifecycle
The Core supervises your worker process with automatic crash recovery:
- Auto-restart on crash with exponential backoff (0s, 0s, 2s, 4s, 8s...).
- Crash loop detection: after 5 crashes within 60 seconds, the worker stops restarting.
- Status tracking: one of
starting,running,stopping,stopped, orcrashed.
| Action | Endpoint |
|---|---|
| Start | POST /api/v1/apps/{appId}/worker/start |
| Stop | POST /api/v1/apps/{appId}/worker/stop |
| Status | GET /api/v1/apps/{appId}/worker/status |
# Check worker status
curl https://<your-ref>.rootcx.com/api/v1/apps/crm/worker/status
# Restart a worker
curl -X POST https://<your-ref>.rootcx.com/api/v1/apps/crm/worker/stop
curl -X POST https://<your-ref>.rootcx.com/api/v1/apps/crm/worker/start
Deployment
Package your backend code and upload via the deploy endpoint. The Core extracts the archive, runs bun install if a package.json is present, and restarts the worker.
cd backend
tar -czf ../app.tar.gz .
cd ..
curl -X POST https://<your-ref>.rootcx.com/api/v1/apps/crm/deploy \
-H "Authorization: Bearer $TOKEN" \
-F "archive=@app.tar.gz"
The deploy pipeline: extract the archive, install dependencies, restart the worker.
Background jobs
The Core provides a durable PostgreSQL-backed job queue. Use it for work that should not block an RPC response: notifications, syncs, exports.
Enqueue a job from any handler:
await fetch(
`${process.env.ROOTCX_RUNTIME_URL}/api/v1/apps/sales_crm/jobs`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payload: { type: "deal_won", dealId: params.dealId }
}),
}
);
Process jobs in the onJob hook:
serve({ /* handlers */ }, {
onJob: async (payload) => {
if (payload.type === "deal_won") {
log.info(`Deal ${payload.dealId} won, notifying team`);
return { notified: true };
}
},
});
Jobs follow the lifecycle pending -> running -> completed / failed. Return a value from onJob to mark as completed; throw to mark as failed. Handlers should be idempotent as a best practice.
See the Job Queue module for full details on job lifecycle, querying, and reliability guarantees.