RootCX
Docs
Pricing
RootCX/RootCXSource Available
Introduction
What is RootCX?Getting StartedHow it Works
Build
ApplicationAI AgentIntegrationDeploying
Platform
CoreAuthenticationRBACData APISecret VaultJob QueueScheduled Jobs (Crons)Audit LogReal-time LogsChannels
Developers
React SDKBackend & RPCManifest ReferenceREST APICLIClaude CodeSelf-Hosting
DocsDevelopersBackend Development

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, email, and authToken.

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:

  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, 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

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).

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