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,email, andauthToken.
The caller.authToken is the JWT bearer token — use it to make authenticated requests to the Core API from your handler.
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, caller?) => any |
A background job or scheduled cron is dispatched. caller contains the user who enqueued the job (if any). 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");
// ...
},
});
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/permissions`,
{ headers: { Authorization: `Bearer ${caller.authToken}` } }
);
const { permissions } = await res.json();
if (!permissions.includes("app:sales_crm: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, 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 |
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"
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. Jobs are automatically retried if the worker crashes.
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 };
}
},
});
Return a value from onJob to mark as completed (archived). Throw to mark as failed (deleted). Handlers should be idempotent as a best practice.
See the Job Queue module for full details. For recurring work, see Scheduled Jobs (Crons).