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).
- 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
# From JSR
deno add @dafu/hermesOr import directly in your deno.json:
{
"imports": {
"@dafu/hermes": "jsr:@dafu/hermes"
}
}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
}
}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];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);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");Hermes ships with two built-in backends. You can also implement the
BackendAdapter interface to create your own.
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.tsDeno 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.
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.tsImplement 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
}
}Define jobs that run on a schedule using every (interval) or cron
(expression) properties. Recurring jobs are registered automatically when
hermes.start() is called.
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...");
}
}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...");
}
}| Feature | Deno KV | BullMQ (Redis) |
|---|---|---|
every support |
Minute divisors of 60, hour divisors of 24, and exactly 1d; exact multiples convert upward (120s → 2m) |
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 (1–2^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.
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 },
);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.
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 },
});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 automaticallyA 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.
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 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 ahook_errorevent 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 ashook_errorinstead. - Calling
next()twice returns the same promise: a job can never run twice. - Forgetting to call
next()fails the job witharoundPerform 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 awaitsnext()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,
)();Production recommendation: always set
worker.defaultJobTimeoutor a per-jobtimeout. Without one, a permanently pendingperform()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.
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.
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);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.
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 downCalling 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.
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 processconfigure() also accepts hooks (only enqueueMetadata — aroundPerform 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.
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 |
| Option | Type | Default | Description |
|---|---|---|---|
path |
string |
undefined |
Custom path for the KV store file |
| 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.
// 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
};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}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 | 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 |
- Duplicate
jobName: Hermes throws at startup if two job classes share the samejobName. - Invalid manifest: Throws if the manifest file does not export an array via
defaultorjobs. - 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 anunknown_jobevent and skips it. - Deno KV queue mismatch: The backend logs
job_skippedwith reasonqueue filteringand 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 thejob_failedevent (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.
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.mdClaude Code picks it up automatically from .claude/skills/. For other agents,
reference the file from your AGENTS.md or rules configuration.
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 jobdeno task testMIT