Skip to content

Repository files navigation

Hermes

A backend-agnostic background job processing library for TypeScript. Define jobs as classes, enqueue them from anywhere, and process them with pluggable backends like Deno KV or Redis (BullMQ).

Features

  • Backend-agnostic: Swap between Deno KV and Redis/BullMQ (or write your own adapter)
  • Job classes: Encapsulate job logic in typed, reusable classes
  • Manifest-based registration: Auto-discover jobs from a single manifest file
  • Delayed jobs: Schedule jobs to run after a specified delay
  • Timeouts and cancellation: Release stuck worker slots and cooperatively abort supported I/O
  • Priorities and retries: BullMQ-native ordering, attempts, and backoff
  • Queue health stats: Inspect counts and detect unusually old active jobs
  • Queue routing: Route jobs by name; BullMQ processes queues independently, while Deno KV filters its global queue at delivery time
  • Structured logging: JSON-formatted lifecycle events for every job
  • Graceful shutdown: Clean worker shutdown with configurable timeouts
  • Deno Deploy compatible: Works out of the box on Deno Deploy with the Deno KV backend

Installation

# From JSR
deno add @dafu/hermes

Or import directly in your deno.json:

{
  "imports": {
    "@dafu/hermes": "jsr:@dafu/hermes"
  }
}

Quick Start

1. Define a Job

Create a job class that extends the base Job class. Each job must declare a unique jobName and a queueName:

// jobs/email_job.ts
import { Job } from "@dafu/hermes";

interface EmailPayload {
  to: string;
  subject: string;
  body: string;
}

export class EmailJob extends Job {
  jobName = "send_email";
  queueName = "emails";

  async perform(jobBody: unknown): Promise<void> {
    const { to, subject, body } = jobBody as EmailPayload;
    console.log(`Sending email to ${to}: ${subject}`);
    // Your email sending logic here
  }
}

2. Create a Manifest

Export all your job classes as an array. Hermes supports both default and named jobs exports:

// jobs/main.ts
import { EmailJob } from "./email_job.ts";
import { ReportJob } from "./report_job.ts";

// Default export
export default [EmailJob, ReportJob];

// OR named export
// export const jobs = [EmailJob, ReportJob];

3. Start a Worker

Choose a backend and point the worker at your manifest:

// worker.ts
import { DenoKvBackend, Hermes } from "@dafu/hermes";

const hermes = Hermes({
  manifest: "./jobs/main.ts",
  backend: DenoKvBackend(),
});

await hermes.start();
console.log("Worker is running");

const shutdown = async () => {
  await hermes.stop();
  Deno.exit(0);
};
Deno.addSignalListener("SIGINT", shutdown);
Deno.addSignalListener("SIGTERM", shutdown);

4. Enqueue Jobs

Before enqueueing, you must configure the backend. You can either start a full Hermes instance or use configure() for enqueue-only processes:

// enqueue.ts
import { configure, DenoKvBackend } from "@dafu/hermes";
import { EmailJob } from "./jobs/email_job.ts";

// Configure the backend (required before calling performLater)
configure({ backend: DenoKvBackend() });

const job = new EmailJob();
await job.performLater({
  to: "user@example.com",
  subject: "Welcome!",
  body: "Thanks for signing up!",
});

console.log("Job enqueued");

Backends

Hermes ships with two built-in backends. You can also implement the BackendAdapter interface to create your own.

Deno KV

Zero-configuration backend using Deno's built-in KV store. Works on Deno Deploy out of the box.

import { DenoKvBackend } from "@dafu/hermes";

// Default (uses Deno's default KV store)
const backend = DenoKvBackend();

// Custom KV path (local development)
const backend = DenoKvBackend({ path: "./my-data.sqlite" });

Run local workers with --unstable-kv. Workers that register recurring Deno KV jobs also require --unstable-cron at runtime (these flags are not needed on Deno Deploy):

deno run --unstable-kv --unstable-cron worker.ts

Deno cron registration names use a readable form of jobName plus a stable hash of the raw name. The total is capped at 64 characters for compatibility with the local Deno runtime, so punctuation collisions and long names are safe. Hermes validates the complete set before registering any cron.

Redis / BullMQ

Production-grade backend powered by BullMQ. It exposes concurrency, priorities, attempts/backoff, bounded retention, and queue statistics. Requires a running Redis instance.

import { BullMQBackend } from "@dafu/hermes/backends/bullmq";

const backend = BullMQBackend({
  connection: {
    host: "localhost",
    port: 6379,
    // password: "secret",
  },
  concurrency: 5, // Process up to 5 jobs concurrently per queue
  defaultQueueName: "default", // Fallback queue name
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: { count: 1000 },
    removeOnFail: { count: 5000 },
  },
});

BullMQ retains the most recent 1,000 completed and 5,000 failed jobs by default. This is a behavior change in 0.3.0 that prevents unbounded Redis growth. Override either value with defaultJobOptions; those options apply to ordinary and recurring jobs, and the option accepts BullMQ's full DefaultJobOptions type. Properties explicitly set to undefined are ignored so they cannot erase the bounded defaults; explicit values such as false and 0 remain valid overrides.

deno run worker.ts

Custom Backend

Implement the BackendAdapter interface to use any queue system:

import type { BackendAdapter, EnqueueOptions } from "@dafu/hermes";
import type { JobPayload } from "@dafu/hermes";

class MyCustomBackend implements BackendAdapter {
  async enqueue(payload: JobPayload, options?: EnqueueOptions): Promise<void> {
    // Add the job to your queue system
  }

  async listen(
    handler: (payload: JobPayload) => Promise<void>,
    options?: { queueNames?: string[]; concurrency?: number },
  ): Promise<void> {
    // Start consuming jobs and call handler() for each one
  }

  async close(options?: { force?: boolean }): Promise<void> {
    // Clean up connections
  }
}

Recurring Jobs

Define jobs that run on a schedule using every (interval) or cron (expression) properties. Recurring jobs are registered automatically when hermes.start() is called.

Interval-based (every)

Use [number][unit] format where unit is s (seconds), m (minutes), h (hours), or d (days). Any positive integer amount is accepted by the core:

export class HealthCheckJob extends Job {
  jobName = "health_check";
  queueName = "default";
  every = "5m"; // Run every 5 minutes

  async perform(): Promise<void> {
    console.log("Running health check...");
  }
}

Cron-based (cron)

Use standard 5 or 6 field cron expressions:

export class DailyReportJob extends Job {
  jobName = "daily_report";
  queueName = "reports";
  cron = "0 9 * * 1-5"; // 9 AM Monday-Friday

  async perform(): Promise<void> {
    console.log("Generating daily report...");
  }
}

Backend Differences

Feature Deno KV BullMQ (Redis)
every support Minute divisors of 60, hour divisors of 24, and exactly 1d; exact multiples convert upward (120s2m) Any safe positive s, m, h, or d interval
cron support Yes Yes
Deduplication Hashed, 64-character-capped names via Deno.cron Automatic via upsertJobScheduler
Overlap prevention Built-in Built-in
Priorities Ignored Lower number runs first (12^21)
Worker concurrency Single global KV listener; setting ignored Configurable per queue worker
Local runtime flag Recurrence requires --unstable-cron in addition to --unstable-kv None

Intervals such as 7m, 5h, 2d, 90s, 90m, and 25h cannot preserve a true elapsed cadence through resetting cron fields and fail during registration with guidance to use BullMQ. BullMQ uses millisecond intervals and accepts these values directly.

Delayed Jobs

Schedule a job to execute after a delay (in milliseconds):

const job = new EmailJob();

// Send the welcome email 5 minutes from now
await job.performLater(
  { to: "user@example.com", subject: "Welcome!", body: "Hi!" },
  { delay: 5 * 60 * 1000 },
);

Priorities

Declare a default on the job or override it per enqueue. BullMQ uses lower numbers as higher priority (1 through 2^21):

export class PaymentJob extends Job {
  jobName = "process_payment";
  queueName = "payments";
  priority = 5;

  async perform(jobBody: unknown): Promise<void> {
    // ...
  }
}

await new PaymentJob().performLater(payload, { priority: 1 });

The priority is also applied to recurring jobs. Deno KV accepts the portable API but ignores priority.

Metadata and Hooks

Per-call metadata

Attach opaque metadata to any enqueue. It rides the payload envelope, is never interpreted by Hermes, and must be JSON/structured-clone serializable — the same constraint jobBody already has:

await new ExportJob().performLater(body, {
  metadata: { requestedBy: user.id, idempotencyKey },
});

enqueueMetadata hook

Register hooks via Hermes({ hooks }) in worker processes or configure({ hooks }) in enqueue-only processes (a web server). Each registration call fully replaces the previous one — omitting hooks or logger clears any earlier registration.

enqueueMetadata runs synchronously inside every performLater() call, before the payload reaches the backend. Its only power is contributing metadata: it receives the read-only payload (built without metadata at that point) and returns a record or undefined. Hook keys are merged under per-call opts.metadata keys — explicit wins on collision. If the merged result is empty, the payload gets no metadata key at all, so a no-hooks enqueue is byte-identical to previous versions.

configure({
  backend: BullMQBackend({ connection }),
  hooks: {
    enqueueMetadata: () => {
      const ctx = getTraceContext(); // e.g. reads AsyncLocalStorage
      return ctx && { traceId: ctx.traceId, parentSpanId: ctx.spanId };
    },
  },
});

// later, in a request handler:
await new SendReceiptJob().performLater({ orderId }); // stamped automatically

A throwing enqueueMetadata hook fails the performLater() call and nothing is enqueued — there is a live caller in the stack who can see and handle the error, and silently dropping metadata would be invisible corruption.

Reading metadata in the job

JobContext.metadata carries payload.metadata verbatim:

async perform(body: unknown, ctx?: JobContext): Promise<void> {
  console.log("requested by", ctx?.metadata?.requestedBy);
}

ctx.metadata is undefined for scheduled/recurring runs and for payloads enqueued by older versions — that is the documented signal for "no ambient context, start fresh". Recurring schedules never receive enqueue-time metadata: stamping boot-time context onto every future tick would be wrong by construction.

aroundPerform hook

aroundPerform wraps every job execution in the worker — the seam for tracing, error reporting, and ambient context (e.g. AsyncLocalStorage). It is registered via Hermes({ hooks }) only; enqueue-only processes have nothing to wrap.

const hermes = Hermes({
  manifest: "./jobs/main.ts",
  backend: BullMQBackend({ connection }),
  hooks: {
    aroundPerform: async (payload, next) => {
      const ctx = payload.metadata // undefined → scheduled run or old payload
        ? childOf(payload.metadata)
        : freshTrace(payload.jobName);
      await runWithTraceContext(ctx, async () => {
        try {
          await next();
        } catch (err) {
          reportError(err, ctx);
          throw err; // rethrow optional: the outcome is protected either way
        }
      });
    },
  },
});

The contract: the wrapper MUST call next() exactly once and SHOULD await it. The job's outcome is next()'s outcome, always — aroundPerform is deliberately outcome-inert, enforced by mechanism rather than convention:

  • A wrapper that swallows next()'s rejection cannot fake a success: the failure is still rethrown to the backend, so retries stay intact, and a hook_error event is logged.
  • A wrapper that throws after next() resolved cannot fail a job that already did its work (no phantom retry / duplicate execution) — the wrapper's error is logged as hook_error instead.
  • Calling next() twice returns the same promise: a job can never run twice.
  • Forgetting to call next() fails the job with aroundPerform completed without invoking next() — the job never executed, so failing loudly is safe.
  • A wrapper that resolves while next() is still pending doesn't detach the job: Hermes awaits next() regardless.

Note the asymmetry with enqueueMetadata: enqueue-hook throws fail the caller, but execution-hook faults never fail the job — at execution time there is no caller to inform, and failing jobs on observability bugs converts monitoring problems into retry storms and duplicate side effects.

Wrapper time counts against the job's timeout budget, and a hung wrapper holds the in-flight slot through graceful shutdown — wrappers should be thin.

There is no middleware framework; composition is plain function composition:

const compose = (...fns: AroundPerform[]): AroundPerform => (payload, next) =>
  fns.reduceRight<() => Promise<unknown>>(
    (n, fn) => async () => {
      await fn(payload, n);
    },
    next,
  )();

Execution Timeouts and Cooperative Cancellation

Production recommendation: always set worker.defaultJobTimeout or a per-job timeout. Without one, a permanently pending perform() can occupy a worker slot forever.

Timeouts may be milliseconds or duration strings using the every grammar:

import type { JobContext } from "@dafu/hermes";

export class FetchReportJob extends Job {
  jobName = "fetch_report";
  queueName = "reports";
  timeout = "30s";

  async perform(_body: unknown, context?: JobContext): Promise<void> {
    await fetch("https://example.com/report", { signal: context?.signal });
  }
}

const hermes = Hermes({
  manifest: "./jobs/main.ts",
  backend,
  worker: { defaultJobTimeout: "2m" },
});

Resolved timeouts must not exceed 2_147_483_647 milliseconds (about 24.8 days), the maximum reliable JavaScript timer delay. Hermes validates every job timeout before registering recurring schedules or starting listeners.

A job's timeout overrides defaultJobTimeout. On expiry Hermes aborts the context signal, logs job_failed, and throws an error whose name is "JobTimeoutError"; the backend then applies its normal retry/failure policy. The error class is intentionally internal, so detect it by name.

The timeout always releases the Hermes/BullMQ worker slot. It cannot forcibly stop JavaScript already running inside perform(): pass context.signal to APIs such as fetch or database clients that support AbortSignal so the underlying I/O is actually cancelled. With retries enabled, a timed-out body that ignores the signal can overlap its retry, so timeout-enabled jobs must be abort-safe and idempotent. Existing jobs that implement only perform(jobBody) remain valid.

Monitoring Queue Health

After start(), BullMQ-backed instances expose one stats entry per manifest queue:

const queues = await hermes.stats();
const unhealthy = queues.find(
  (queue) => (queue.oldestActiveJobAgeMs ?? 0) > 5 * 60_000,
);
if (unhealthy) throw new Error(`Stuck queue: ${unhealthy.queueName}`);

Each entry includes waiting, active, delayed, failed, and completed counts plus oldestActiveJobAgeMs when an active job has a processing timestamp. For BullMQ, waiting is the full ready backlog: ordinary waiting jobs plus prioritized jobs. The optional prioritized count exposes the raw prioritized subset. Calling stats() before start(), after stop(), or with a backend such as Deno KV that does not implement the optional capability throws a clear error.

Graceful Shutdown

hermes.stop() stops intake and gives in-flight work up to worker.gracefulShutdownTimeout milliseconds to finish (default: 30_000). If that deadline expires, Hermes logs worker_force_closed and asks the backend to force-close. For BullMQ this returns without waiting for in-flight handlers, so their locks can expire and another worker can recover the jobs. The graceful deadline starts when stop() is requested, including while start() is still settling. Hermes waits at most another fixed 5 seconds for the force-close attempt, so a stuck backend connection cannot make stop() unbounded. stop() is idempotent. The configured shutdown timeout must be a positive safe integer no greater than 2_147_483_647 milliseconds (about 24.8 days).

Backend shutdown is terminal. After close() (including through hermes.stop()), Deno KV and BullMQ reject new enqueue, listen, and recurring registration operations; BullMQ also rejects queue-stat reads. Create and configure a new backend instance before enqueueing more work.

On graceful close, Deno KV drains tracked queue handlers before closing its KV handle. BullMQ pauses workers, drains active handlers, and closes Redis connections. Hermes also waits for timed-out perform() bodies that are still running. On force close, Deno KV closes its handle immediately and BullMQ disconnects without waiting for handlers; neither path waits for orphaned job bodies beyond the graceful deadline.

Install both termination handlers in worker processes:

const shutdown = async () => {
  await hermes.stop();
  Deno.exit(0);
};
Deno.addSignalListener("SIGINT", shutdown);
Deno.addSignalListener("SIGTERM", shutdown);

Multi-Queue Architecture

Jobs declare which queue they belong to via queueName. This lets you run specialized workers that only process specific queues, or a single worker that handles everything.

// A high-priority job
export class PaymentJob extends Job {
  jobName = "process_payment";
  queueName = "payments";

  async perform(jobBody: unknown): Promise<void> {
    // ...
  }
}

// A low-priority job
export class ReportJob extends Job {
  jobName = "generate_report";
  queueName = "reports";

  async perform(jobBody: unknown): Promise<void> {
    // ...
  }
}

Hermes automatically extracts all unique queue names from the manifest and listens on each one. With the BullMQ backend, each queue gets its own dedicated BullMQ Worker for true multi-queue processing. Deno KV has one global queue and filters after delivery: a mismatched payload logs job_skipped and rejects the delivery so Deno KV's native retry/backoff can offer it to another listener. Redelivery to a listener that owns the queue is best-effort. If no listener accepts it, Deno KV exhausts its retry schedule and applies its undelivered handling; persistence requires keysIfUndelivered, which Hermes does not currently expose through EnqueueOptions.

API Reference

Hermes(params)

Creates a Hermes instance. Returns an object with start(), stop(), and stats() methods.

const hermes = Hermes({
  manifest: "./jobs/main.ts", // Path to your jobs manifest file
  backend: DenoKvBackend(), // A BackendAdapter instance
  worker: {
    concurrency: 5,
    defaultJobTimeout: "2m",
    gracefulShutdownTimeout: 10000, // Shutdown timeout in ms (default: 30000)
  },
});

await hermes.start(); // Load manifest, register jobs, start worker
await hermes.stop(); // Gracefully shut down

Calling start() consumes the instance's single startup attempt. If startup fails, later start() calls report the original failure and require creating a new Hermes instance; retrying the same instance is unsafe because startup may already have registered durable schedules.

Calling stop() before the first start() makes that later start() reject and requires a new instance. If stop() is requested while start() is already in progress, startup cancellation is cooperative: start() may resolve without a live worker after the current startup step settles, while stop() remains bounded by the shutdown deadline.

configure({ backend, hooks?, logger? })

Sets the global backend for enqueuing jobs without starting a worker. Use this in processes that only enqueue (e.g., a web server):

import { configure, DenoKvBackend } from "@dafu/hermes";

configure({ backend: DenoKvBackend() });
// Now you can call job.performLater() anywhere in this process

configure() also accepts hooks (only enqueueMetadataaroundPerform is excluded by type because enqueue-only processes never execute jobs) and a logger sink. Registration replaces: omitting hooks or logger clears any previous registration.

Job (abstract class)

Base class for all jobs.

Property / Method Type Description
jobName string (abstract) Unique identifier for the job type
queueName string (abstract) Queue this job is dispatched to
every string? Interval schedule, e.g. "5m", "1h", "7d"
cron string? Cron expression, e.g. "0 9 * * 1-5"
timeout string | number? Per-job execution timeout; overrides the worker default
priority number? Default BullMQ priority (lower is higher)
perform(jobBody, context?) Promise<unknown> (abstract) The work the job does; context.signal supports cancellation
performLater(jobBody?, opts?) Promise<void> Enqueue the job for async processing
isRecurring() boolean Whether the job has a recurring schedule

DenoKvBackend(options?)

Option Type Default Description
path string undefined Custom path for the KV store file

BullMQBackend(options)

Option Type Default Description
connection.host string undefined Redis host
connection.port number undefined Redis port
connection.password string undefined Redis password
connection.url string undefined Redis connection URL
concurrency number 1 Max concurrent jobs per queue worker
defaultQueueName string "default" Fallback queue when none is specified
defaultJobOptions.attempts number 1 Total BullMQ processing attempts
defaultJobOptions.backoff { type: "fixed" | "exponential"; delay: number } undefined Retry delay policy
defaultJobOptions.removeOnComplete boolean | number | { age?, count? } { count: 1000 } Completed-job retention
defaultJobOptions.removeOnFail boolean | number | { age?, count? } { count: 5000 } Failed-job retention

worker.concurrency passed to Hermes() takes precedence over the backend's concurrency; the fallback is 1.

Types

// The payload structure sent through the queue
type JobPayload = {
  jobName: string;
  queueName: string;
  jobBody: unknown;
  metadata?: Record<string, unknown>;
};

// Options for performLater
type PerformLaterOptions = {
  delay?: number; // Delay in milliseconds
  priority?: number; // BullMQ: lower values run first
  metadata?: Record<string, unknown>; // Opaque; merged over hook metadata
};

type JobContext = {
  signal: AbortSignal;
  metadata?: Record<string, unknown>; // payload.metadata, verbatim
};

// Worker configuration
type WorkerConfig = {
  concurrency?: number;
  defaultJobTimeout?: string | number; // Maximum: 2_147_483_647ms
  gracefulShutdownTimeout?: number; // Default: 30000ms; same maximum
};

interface QueueStats {
  queueName: string;
  counts: {
    waiting: number;
    active: number;
    delayed: number;
    failed: number;
    completed: number;
    prioritized?: number; // Raw BullMQ subset included in waiting
  };
  oldestActiveJobAgeMs?: number;
}

// Main configuration
type HermesParams = {
  manifest: string; // Path to the manifest file
  backend: BackendAdapter; // Backend instance
  worker?: WorkerConfig;
  hooks?: HermesHooks; // enqueueMetadata / aroundPerform
  logger?: LoggerSink; // (event: LogEvent) => void
};

Logging

Hermes emits structured JSON logs for all job lifecycle events:

{"timestamp":"2026-01-15T12:00:00.000Z","event":"worker_started","registeredJobs":3,"config":{"queueNames":["emails","reports"]}}
{"timestamp":"2026-01-15T12:00:01.000Z","event":"job_received","jobName":"send_email","queueName":"emails"}
{"timestamp":"2026-01-15T12:00:01.001Z","event":"job_started","jobName":"send_email","queueName":"emails"}
{"timestamp":"2026-01-15T12:00:01.050Z","event":"job_succeeded","jobName":"send_email","queueName":"emails","durationMs":49}

Logger sink

By default every event goes to console.log(JSON.stringify(event)). Pass a logger sink to Hermes() or configure() to redirect the stream:

import type { LogEvent } from "@dafu/hermes";

const hermes = Hermes({
  manifest: "./jobs/main.ts",
  backend: DenoKvBackend(),
  logger: (event: LogEvent) => posthog.capture("hermes_log", event),
});

The sink is called synchronously with each structured event and its return value is ignored — an async sink must do its own buffering and flushing; Hermes will not await I/O on the logging hot path. The sink should not throw, but Hermes guards anyway: on a throw, the original event falls back to console.log followed by a single logger_error event (written to the console directly — a broken sink can neither lose events nor recurse, and it cannot take down job dispatch).

The four job lifecycle events (job_received, job_started, job_succeeded, job_failed) carry a metadata field when the payload does. Hermes's own lines fire outside any context an aroundPerform wrapper establishes, so the echoed metadata is what makes them correlatable by the sink. Mind the size: metadata rides both the payload envelope and the log stream — you own its size and content.

Errors are strings in LogEvent (JSON-safe). Integrations that need the raw Error object (stack, cause) observe it in aroundPerform via next()'s rejection — log transport and error reporting are separate concerns.

Event Types

Event Description
worker_started Worker is listening for jobs
job_received A job payload was dequeued
job_started Job perform() is being called
job_succeeded Job completed without errors
job_failed Job threw an error (includes error message and duration)
worker_job_failed BullMQ marked a job attempt failed (includes job ID and attempts made)
job_stalled BullMQ detected a stalled job
job_skipped Deno KV rejected a delivery because its queue was not configured
unknown_job Received a job with an unregistered jobName
hook_error An aroundPerform wrapper misbehaved (the job outcome was unaffected)
logger_error The configured logger sink threw; the event fell back to the console
recurring_job_registered A recurring job schedule was registered at startup
recurring_job_skipped A recurring job schedule registration was skipped
worker_error A BullMQ worker connection/runtime error occurred
queue_error A BullMQ enqueue-side queue connection error occurred
worker_closed A BullMQ queue worker closed
worker_stopping hermes.stop() began
worker_force_closed The graceful shutdown deadline elapsed
worker_stopped Worker has fully shut down

Error Handling

  • Duplicate jobName: Hermes throws at startup if two job classes share the same jobName.
  • Invalid manifest: Throws if the manifest file does not export an array via default or jobs.
  • Manifest not found: Throws with a clear message if the manifest path is wrong.
  • Unknown job: If a queued message references an unregistered jobName, the worker logs an unknown_job event and skips it.
  • Deno KV queue mismatch: The backend logs job_skipped with reason queue filtering and rejects the delivery. Deno KV retries it using its native backoff and may redeliver it to another listener; an unowned queue eventually reaches Deno KV's undelivered handling.
  • Job execution failure: If perform() throws, the error is logged with the job_failed event (including duration) and re-thrown to the backend, which can handle retries if supported.
  • Job timeout: Hermes aborts the job context, logs and rethrows a JobTimeoutError, and frees the worker slot. Configure attempts/backoff on BullMQ if timed-out work should retry.

Agent Skill

The repo ships an agent skill (skills/hermes/SKILL.md) that teaches coding agents (Claude Code, Cursor, etc.) how to work with Hermes: defining jobs, manifest rules, backend configuration, every/cron constraints per backend, timeouts, retries, hooks, and the logger sink.

Copy it into your project with:

mkdir -p .claude/skills/hermes && curl -fsSL \
  https://raw.githubusercontent.com/dannyfuf/hermes/main/skills/hermes/SKILL.md \
  -o .claude/skills/hermes/SKILL.md

Claude Code picks it up automatically from .claude/skills/. For other agents, reference the file from your AGENTS.md or rules configuration.

Running the Examples

The repository includes working examples for both backends:

# Deno KV backend
deno task worker          # Start the worker
deno task enqueue         # Enqueue a job

# Redis/BullMQ backend (requires a running Redis instance)
deno task worker:redis    # Start the worker
deno task enqueue:redis   # Enqueue a job

Running Tests

deno task test

License

MIT

About

A backend-agnostic background job processing library for Typescript

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages