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.
nexusbrain package. It exports TypeScript source files, so run the worker with a runtime that loads TypeScript directly, such as Bun.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.
| Export | From | Use |
|---|---|---|
MinionWorker | nexusbrain/minions | The worker. new MinionWorker(engine, opts), then register, then start. |
MinionQueue | nexusbrain/minions | Submits jobs from code: queue.add(name, data, opts). |
UnrecoverableError | nexusbrain/minions | Throw it when retrying cannot help. The job goes straight to dead. |
MinionHandler, MinionJobContext | nexusbrain/minions (types) | Types for your handler and its argument. |
loadConfig, toEngineConfig | nexusbrain/config | Read the brain config the CLI uses. |
createEngine | nexusbrain/engine-factory | Create the database engine from that config. |
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.
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.tsYou should seeThe process keeps running and waits for jobs.
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().
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).| Field or method | What it gives you |
|---|---|
id, name, data | The job ID, its name and the JSON parameters it was submitted with. |
attempts_made | How many earlier attempts already ran. |
signal | An AbortSignal that fires on timeout, cancel, pause or lost lock. Check it in long loops. |
shutdownSignal | An AbortSignal that fires only when the worker process gets SIGTERM or SIGINT. |
deadlineAtMs | The 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. |
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 }).
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.
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.
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".
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.
cat ~/.nexusbrain/migrations/pending-host-work.jsonlYou should seeOne JSON line per task, with handler, cron_schedule, manifest_path and "status":"pending".
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.
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.
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 --yesYou should seenexusbrain apply-migrations --list shows v0.11.0 as applied.
| Handler name in the cron entry | What the migration does |
|---|---|
A built-in: sync, embed, lint, import, extract, backlinks, autopilot-cycle | Rewrites 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 else | Leaves 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. |
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.
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.
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.
Move your worker.register calls above await worker.start().
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.
Throw UnrecoverableError for bad input instead of a plain Error. The job then goes to dead on the first attempt.
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.