Guides / Brain

Registering job handlers

How a host app registers the handlers its background jobs call.

A job handler is the function a background worker runs for one kind of job. The brain ships handlers for its own work, such as sync, embed and extract. If your host app (an agent platform such as OpenClaw, or your own service) has scheduled tasks of its own, you register a handler for each one in a worker you start, and those tasks then run through the brain's durable job queue with retries, timeouts and status.

This guide shows the registration API with a working worker file, how to test a handler, and how it connects to nexusbrain apply-migrations, which moves scheduled agent tasks onto the queue and leaves a to-do for every task name it does not know.

Time
30 minutes
For
Developers of a host app that runs scheduled agent tasks against a brain

Before you start

  • A brain on the Postgres engine. A worker is a separate long-running process, and the local PGLite engine holds an exclusive file lock that blocks other processes.
  • Your host code can import the nexusbrain package. It exports TypeScript source files, so run the worker with a runtime that loads TypeScript directly, such as Bun.
  • You know what a job and a worker are. Shell jobs explains the queue from the command line.

01How handlers are found

Every job has a name. A worker only claims jobs whose name it has a handler for, so the stock nexusbrain jobs work process never picks up a job called ea-inbox-sweep. Such a job waits until a worker that registered ea-inbox-sweep is running.

You register handlers with worker.register(name, handler) on a MinionWorker, before calling worker.start(). A handler is an async function that receives a job context and returns the job's result.

ExportFromUse
MinionWorkernexusbrain/minionsThe worker. new MinionWorker(engine, opts), then register, then start.
MinionQueuenexusbrain/minionsSubmits jobs from code: queue.add(name, data, opts).
UnrecoverableErrornexusbrain/minionsThrow it when retrying cannot help. The job goes straight to dead.
MinionHandler, MinionJobContextnexusbrain/minions (types)Types for your handler and its argument.
loadConfig, toEngineConfignexusbrain/configRead the brain config the CLI uses.
createEnginenexusbrain/engine-factoryCreate the database engine from that config.

02Write a worker that registers your handler

  1. Create a worker file in your host repo.

    The object you return is saved as the job's result. A normal thrown error fails the attempt, and the job is retried until it runs out of attempts. UnrecoverableError skips the retries.

    import { MinionWorker, UnrecoverableError } from 'nexusbrain/minions';
    import type { MinionJobContext } from 'nexusbrain/minions';
    import { loadConfig, toEngineConfig } from 'nexusbrain/config';
    import { createEngine } from 'nexusbrain/engine-factory';
    
    const config = loadConfig();
    if (!config) throw new Error('No brain config found. Run nexusbrain init first.');
    const engineConfig = toEngineConfig(config);
    const engine = await createEngine(engineConfig);
    await engine.connect(engineConfig);
    
    const worker = new MinionWorker(engine, { queue: 'default', concurrency: 2 });
    
    worker.register('ea-inbox-sweep', async (job: MinionJobContext) => {
      const mailbox = job.data.mailbox;
      if (typeof mailbox !== 'string') {
        throw new UnrecoverableError('ea-inbox-sweep: data.mailbox is required');
      }
      await job.log(`sweeping ${mailbox}`);
    
      // Your host-specific work goes here. Stop early if the job is cancelled
      // or times out.
      if (job.signal.aborted) throw new Error('aborted');
    
      await job.updateProgress({ step: 'done' });
      return { mailbox, swept: true };
    });
    
    // Register every other host handler here, before start().
    
    await worker.start(); // runs until SIGTERM or SIGINT
    await engine.disconnect();

    You should seeThe file type-checks against the nexusbrain package.

  2. Start the worker.

    start() refuses to run with no handlers: "No handlers registered. Call worker.register(name, handler) before start()." It stops cleanly on SIGTERM or SIGINT and gives running jobs time to finish.

    bun run worker.ts

    You should seeThe process keeps running and waits for jobs.

Warning

The worker checks its own health once a minute by default. If the database stays unreachable or the worker stalls, it emits an unhealthy event, and if nothing listens for that event it exits with code 1 so a process manager can restart it. Run the worker under systemd, Docker or a similar manager, or add your own worker.on('unhealthy', ...) listener before start().

03Use the job context

  • Worker options you can pass to new MinionWorker(engine, opts) include queue (default default), concurrency (default 1), lockDuration (default 30000 ms), pollInterval (default 5000 ms), maxRssMb (0 turns the memory watchdog off) and healthCheckInterval (default 60000 ms, 0 turns it off).
  • A worker only takes jobs from its own queue. Submit to the same queue name the worker uses.
Field or methodWhat it gives you
id, name, dataThe job ID, its name and the JSON parameters it was submitted with.
attempts_madeHow many earlier attempts already ran.
signalAn AbortSignal that fires on timeout, cancel, pause or lost lock. Check it in long loops.
shutdownSignalAn AbortSignal that fires only when the worker process gets SIGTERM or SIGINT.
deadlineAtMsThe job's deadline as epoch milliseconds, or null if it has no timeout.
log(message)Adds a line to the job's history, shown by nexusbrain jobs get.
updateProgress(progress)Stores structured progress, any JSON value.
updateTokens(tokens)Adds to the job's token usage count.
isActive()Whether this worker still holds the job's lock.
readInbox()Reads unread messages sent to the job and marks them read.

04Test the handler with a real job

To submit from your own code instead of the CLI, create new MinionQueue(engine) and call await queue.add('ea-inbox-sweep', { mailbox: '[email protected]' }, { max_attempts: 3 }).

  1. Submit a job with the handler's name while your worker runs.

    Do not add --follow. It runs the job inside the CLI, which only knows the built-in handlers, and stops with "Unknown job type".

    nexusbrain jobs submit ea-inbox-sweep --params '{"mailbox":"[email protected]"}'

    You should seeThe new job printed as JSON with its id.

  2. Check the result.

    nexusbrain jobs get <id>

    You should see"Job #<id>: ea-inbox-sweep (COMPLETED)", your log lines in History, and a Result line with the object your handler returned.

  3. Check that bad input is not retried.

    nexusbrain jobs submit ea-inbox-sweep --params '{}'

    You should seeAfter the worker picks it up, nexusbrain jobs get <id> shows the job as DEAD with the error "ea-inbox-sweep: data.mailbox is required".

05Finish the Minions migration

The v0.11.0 migration moves scheduled agent tasks onto the queue. It looks for cron manifests named cron/jobs.json or cron.json in ~/.claude and ~/.openclaw, plus any directory you pass with --host-dir. For each entry of kind agentTurn, session or skill, it checks the handler name.

  1. See which handlers need registering.

    cat ~/.nexusbrain/migrations/pending-host-work.jsonl

    You should seeOne JSON line per task, with handler, cron_schedule, manifest_path and "status":"pending".

  2. Register each listed handler in your worker and deploy it.

    Use the handler name from the handler field exactly, as in the worker file above.

    You should seeYour worker is running with every listed handler registered.

  3. Point each cron entry for your own handlers at the queue.

    The migration only rewrites built-in names, so change these entries yourself to submit the job, for example nexusbrain jobs submit ea-inbox-sweep --params '{}'. Your worker then runs it.

    You should seeThe scheduler submits a job at each run and nexusbrain jobs list shows it completing.

  4. Run the migrations again.

    The partial v0.11.0 migration resumes. It never adds the same handler and manifest twice, so when nothing new is found it is recorded as complete. Use --dry-run first to see the plan, and --host-dir <path> if your cron manifest lives somewhere else.

    nexusbrain apply-migrations --yes

    You should seenexusbrain apply-migrations --list shows v0.11.0 as applied.

Handler name in the cron entryWhat the migration does
A built-in: sync, embed, lint, import, extract, backlinks, autopilot-cycleRewrites the entry to kind shell with a nexusbrain jobs submit command (with --follow on PGLite, with an idempotency key on Postgres), after saving a .bak.<timestamp> copy of the file.
Anything elseLeaves the entry unchanged and adds a cron-handler-needs-host-registration line to ~/.nexusbrain/migrations/pending-host-work.jsonl. The migration is recorded as partial.
Note

A migration that stays partial on 3 runs in a row is marked wedged. Fix the cause, then run nexusbrain apply-migrations --force-retry 0.11.0 and nexusbrain apply-migrations --yes.

06Troubleshooting

Jobs with your handler's name stay in waiting.

No running worker has registered that name on that queue. Check that your worker is running, that register is called before start(), and that the job's queue (--queue, default default) matches the worker's queue option.

Error: Unknown job type '<name>'.

You submitted with --follow, which runs the job inside the CLI with only the built-in handlers. Submit without --follow and let your worker run it.

No handlers registered. Call worker.register(name, handler) before start().

Move your worker.register calls above await worker.start().

The worker exits with "[health] FATAL: ... No 'unhealthy' listener registered; defaulting to process.exit(1) for process-manager restart."

The worker could not reach the database or stopped making progress. Fix the database connection, and run the worker under a process manager that restarts it, or add a worker.on('unhealthy', ...) listener.

A handler keeps retrying input that can never work.

Throw UnrecoverableError for bad input instead of a plain Error. The job then goes to dead on the first attempt.

Migration v0.11.0 is WEDGED (3+ consecutive partials with no completion).

Check nexusbrain apply-migrations --list for the reason, fix it, then run nexusbrain apply-migrations --force-retry 0.11.0 followed by nexusbrain apply-migrations --yes.