RootCX
Docs
Pricing
RootCX/RootCX
Introduction
What is RootCX?How it Works
Build
Getting StartedApplicationAI AgentIntegrationDeploying
Platform
CoreAuthenticationRBACData APISecret VaultJob QueueAudit LogReal-time Logs
Developers
QuickstartReact SDKBackend & RPCManifest ReferenceREST APISelf-Hosting
RootCXDevelopersBackend

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 the params field in the RPC request body.
  • caller — the authenticated user, or null for system calls. Contains userId, username, and authToken.

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:

  1. index.ts
  2. index.js
  3. main.ts
  4. main.js
  5. src/index.ts
  6. src/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, or crashed.
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.

PreviousReact SDKNextManifest Reference

On this page

The serve() API
Lifecycle hooks
Error handling
Checking permissions
Globals
Entry point resolution
Environment variables
Process lifecycle
Deployment
Background jobs