Mandala
Karma RPA

Client Protocol

The wire protocol reference for building a Karma RPA client.

This protocol is intentionally generic -- nothing below is specific to any particular automation vendor. Anyone can build a Karma RPA client in any language that has a Socket.IO client library; Mandala doesn't ship or require a specific client implementation.

This page is the reference for the program that runs on your own machine and talks to Mandala. If you haven't registered a client and obtained a credential yet, do that first: Registering a Client.

Connecting

Mint a connect token -- before every connection attempt (initial connect, and every reconnect), call:

POST /api/rpa/clients/{id}/connect-token
// Request body
{ "credential": "<your stored credential>", "machineFingerprint": { "...": "optional" } }
// Response
{ "success": true, "data": { "token": "<connect token>", "expiresIn": 60 } }

This token is deliberately short-lived (about a minute) -- mint it right before you connect, don't cache it. Your long-lived credential is only ever sent to this one HTTPS endpoint; it never travels over the realtime connection itself.

Open the connection using a standard Socket.IO client, pointed at the same base URL your Mandala workspace is on, with the connect token in the handshake auth field -- not a header, not a query parameter:

import { io } from 'socket.io-client'

const socket = io('https://<your-mandala-host>', {
  path: '/rpa/socket.io/',
  transports: ['websocket', 'polling'],
  auth: { token: connectToken },
})

A rejected connection closes with an error describing why -- typically an invalid/expired connect token, a client that's been revoked or isn't in an active state, or (see Security) a detected duplicate connection.

Publish your capability manifest immediately on every successful connect (including reconnects -- see below).

Send heartbeats on a steady interval for as long as you're connected.

Events Reference

EventDirectionPurpose
rpa:manifestClient -> ServerPublish (replace) the full list of capabilities this client offers
rpa:heartbeatClient -> ServerKeep-alive; also what tells Mandala's dashboard this client is online
rpa:job:dispatchServer -> Client"Start this job" -- requires a quick accept/reject ack
rpa:job:resultClient -> Server"Here's what happened" -- sent whenever the job actually finishes, no time limit

rpa:manifest (client -> server)

Sent on every connect and reconnect. This replaces whatever you published last time -- there's no partial update, so always send your complete, current list.

socket.emit(
  'rpa:manifest',
  {
    capabilities: [
      {
        name: 'submit_purchase_order',
        description: 'Fills and submits a purchase order in the warehouse ERP',
        inputSchema: { type: 'object', properties: { sku: { type: 'string' }, qty: { type: 'number' } } },
      },
      // ...
    ],
  },
  (ack) => {
    // { success: true, count: 1 }
  }
)

Limits are enforced server-side and silently applied, not rejected: name is capped at 200 characters, description at 2000 characters, and at most 500 capability entries are kept per manifest. inputSchema is stored as inert data (never executed) and is currently not shown or validated anywhere in the Mandala UI -- it exists so you have a place to declare a capability's expected input shape for anyone integrating with it, but callers must already know that shape out of band.

rpa:heartbeat (client -> server)

socket.emit(
  'rpa:heartbeat',
  { cpuPercent: 12.5, memPercent: 40, diskFreeGB: 100, uptimeSeconds: 3600 },
  (ack) => {
    // { success: true }
  }
)

All fields are optional and currently informational only (logged, not yet surfaced on a health dashboard). Send this roughly every 20 seconds. That cadence is deliberately well under two server-side thresholds you should stay clear of: a connection that goes quiet for about a minute is dropped as unresponsive, and a reconnect that takes longer than about 45 seconds after your last heartbeat may be treated as a new, possibly-duplicate connection rather than a routine resume (see Security). Missing one or two heartbeats in a row is harmless; a client that goes fully quiet for that long is not.

rpa:job:dispatch (server -> client)

Sent when a workflow or agent dispatches a job to one of your capabilities. You must acknowledge within 5 seconds:

socket.on('rpa:job:dispatch', (payload, ack) => {
  // payload: { jobId: string, capabilityName: string, input: unknown }

  if (!haveCapability(payload.capabilityName)) {
    ack({ accepted: false, error: `Unknown capability: ${payload.capabilityName}` })
    return
  }

  ack({ accepted: true })
  runCapabilityInBackground(payload) // report the outcome later via rpa:job:result
})

This ack is deliberately only an acknowledgment that you've started the job -- not its result. A capability that takes 5 seconds and one that takes 10 minutes both ack the same way, immediately; the actual outcome is reported later, separately, via rpa:job:result, whenever it's actually ready. If you don't ack in time (or explicitly reject), the dispatch fails fast on Mandala's side rather than leaving the caller waiting.

rpa:job:result (client -> server)

Sent once, whenever the job you accepted actually finishes -- seconds or minutes later, with no time limit from Mandala's side:

// Success
socket.emit('rpa:job:result', {
  jobId: payload.jobId,
  success: true,
  output: { confirmationNumber: 'PO-48213' },
  cost: { total: 0.02, billed: 0.02 }, // optional
})

// Failure
socket.emit('rpa:job:result', {
  jobId: payload.jobId,
  success: false,
  error: 'ERP session timed out before the form could be submitted',
})

cost is optional and only meaningful if this capability has a real, meterable cost to run (total is what it cost; billed is what's actually charged -- normally the same number). Most capabilities have no cost to report and can omit the field entirely.

If Mandala already considered this job settled by the time your result arrives -- for example, a canvas block's own wait timed out first, or a long-silent job was already marked timed-out -- your result is accepted and safely ignored rather than erroring. Design your client to fire-and-forget this event; there's nothing further to retry or reconcile on your side.

A Minimal Client, End to End

import { io } from 'socket.io-client'

async function connect(clientId, credential) {
  const { data } = await mintConnectToken(clientId, credential) // POST .../connect-token
  const socket = io('https://<your-mandala-host>', {
    path: '/rpa/socket.io/',
    auth: { token: data.token },
  })

  socket.on('connect', () => {
    socket.emit('rpa:manifest', { capabilities: myCapabilities })
    setInterval(() => socket.emit('rpa:heartbeat', currentMachineStats()), 20_000)
  })

  socket.on('rpa:job:dispatch', async (job, ack) => {
    if (!myCapabilities.some((c) => c.name === job.capabilityName)) {
      return ack({ accepted: false, error: 'unknown capability' })
    }
    ack({ accepted: true })
    try {
      const output = await runCapability(job.capabilityName, job.input)
      socket.emit('rpa:job:result', { jobId: job.jobId, success: true, output })
    } catch (err) {
      socket.emit('rpa:job:result', { jobId: job.jobId, success: false, error: String(err) })
    }
  })

  socket.on('disconnect', () => {
    // Re-mint a connect token and reconnect -- see the heartbeat cadence
    // note above for how quickly you should aim to do this.
  })

  return socket
}
Client Protocol