What is ngn?
ngn is a cron scheduler for JavaScript and TypeScript task files.
Write tasks as plain files, run one command, and every run — status, logs, timings, key-value state —
is recorded into one SQLite file. Each task also gets its own SQLite database, with nothing to configure.
Read the history back with ngn sql from the terminal, or in a browser dashboard.
Installation
One package. Install it globally, or run it without installing:
bash
npm install -g @apisurf/ngn
ngn <command>
# or, without installing
npx @apisurf/ngn <command>
Or add it to a project, for the TaskContext and defineConfig types:
bash
npm i @apisurf/ngn
yarn add @apisurf/ngn
pnpm add @apisurf/ngn
Workflow examples
Examples
bash
# Scaffold ngn.config.ts and tasks/, then write a task file
ngn init
ngn add scrape.ts
# Schedule every matching task (blocks until Ctrl-C)
ngn run
# Run a subset — quote the glob
ngn run --match "tasks/reports/**/*.ts"
# Run one task now, then exit
ngn run:once tasks/scrape.ts
# Schedule one task on your own pattern
ngn run:single tasks/cleanup.ts -t "*/10 * * * * *"
# Read the run history back
ngn sql "SELECT * FROM task_runs ORDER BY id DESC LIMIT 20"
| Command | Description |
ngn init | Initialize ngn in current directory |
ngn add <file> | Create a new task file in tasks/ |
ngn run | Start scheduler and dashboard |
ngn run --match <pattern> | Run only tasks matching glob pattern |
ngn run:single <file> | Run single task with optional custom timing |
ngn run:once <file> | Execute task once, then exit (no scheduling) |
ngn sql <query> | Query the run history — table, --json or --csv |
Writing Tasks
A task file is as simple as this:
typescript
import { TaskContext } from "@apisurf/ngn";
// Cron pattern (6 fields: second minute hour day month weekday)
export const timing = "0 */5 * * * *"; // Every 5 minutes
// Task function
export const task = async (ctx: TaskContext) => {
// Your code here
};
Task Context
Every task receives a context object with built-in utilities. Anything else a task needs, it imports itself — only your code is bundled, imports resolve from your own node_modules:
typescript
export const task = async (ctx: TaskContext) => {
// Task metadata
const { fileTaskId, fileTaskVersionId, file, tasksRootDir } = ctx.meta;
// Environment variables from .env
const apiKey = ctx.env!.API_KEY;
// Key-value storage
await ctx.kv.set("lastRun", new Date().toISOString());
const lastRun = await ctx.kv.get("lastRun");
await ctx.kv.delete("lastRun");
// Structured logging (visible in dashboard)
await ctx.log.info("Processing started");
await ctx.log.warning("Rate limit approaching");
await ctx.log.error("Failed to connect");
// Performance timing
const recordTime = ctx.timing.start("api-call");
await fetch("https://api.example.com");
await recordTime(); // Recorded with the run
// The task's own SQLite database — see below
await ctx.sqlite.execute("SELECT 1");
};
Lifecycle Hooks
Control task execution with optional exports:
typescript
// Runs before task. Returning true skips the run — no other hook fires
export const shouldSkip = async (ctx: TaskContext) => {
const lastRun = await ctx.kv.get("lastRun");
return lastRun === new Date().toDateString();
};
// Runs after a successful execution
export const onSuccess = async (ctx: TaskContext) => {
await ctx.log.info("Done");
};
// Runs after a failed execution
export const onError = async (err: Error, ctx: TaskContext) => {
await ctx.log.error(err.message);
};
// Runs after success or failure. Not called on a skipped run
export const onComplete = async (ctx: TaskContext) => {};
Configuration
Create an ngn.config.ts with defineConfig. Point dbPath at a file: URL to keep the run history — the :memory: default keeps nothing once the process exits:
ngn.config.ts
import { defineConfig } from "@apisurf/ngn";
export default defineConfig({
dbPath: "file:./ngn.sqlite",
port: 4545,
match: ["tasks/**/*.ts"],
envFile: ".env"
});
| Option | Example | Description |
dbPath | :memory: or file:./ngn.sqlite | Where ngn records runs, logs and timings |
port | 4545 | Port of the loopback endpoint ngn run keeps for the live task editor |
match | ["tasks/**/*.ts"] | Glob patterns for task file discovery |
envFile | .env | Environment variables file to load into ctx.env |
Storing data with ctx.sqlite
Every task gets its own SQLite database, with nothing to configure. It lives next to the task file —
tasks/scrape.ts writes to
tasks/scrape.db — and the file is only created once the task actually uses it.
This is your data, separate from the database dbPath points at.
tasks/scrape.ts
export const task = async (ctx: TaskContext) => {
await ctx.sqlite.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT, seen TEXT)");
await ctx.sqlite.execute("INSERT INTO pages (url, seen) VALUES (?, ?)", [
"https://example.com",
new Date().toISOString(),
]);
const { rows } = await ctx.sqlite.execute("SELECT COUNT(*) AS n FROM pages");
await ctx.log.info(`${rows[0].n} pages`);
};
| Member | Does |
execute(sql, args?) | One statement against the task's own database |
batch(statements) | Several statements in one round trip |
client | The raw libsql client, for anything the above does not cover |
path | Absolute path of the task's own database file |
initDB({ file, migrations }) | Open another database in the task's folder, running migrations once each |
destroyDB(file) | Delete a database file in the task's folder |
typescript
const cache = await ctx.sqlite.initDB({
file: "cache.db",
migrations: [
{ id: "2026-01-13-001", up: "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)" },
{ id: "2026-01-14-001", up: "ALTER TABLE items ADD COLUMN qty INTEGER" },
],
});
await cache.execute("INSERT INTO items (name, qty) VALUES (?, ?)", ["widget", 3]);
A task can only reach databases inside its own folder — absolute paths and paths that climb out with
.. are rejected. Connections are held open between runs and closed on shutdown.
Reading what your tasks did
Runs, logs and timings go into the one SQLite file dbPath points at.
There are two ways to read it, and neither needs the scheduler running.
From the terminal
ngn sql takes any query SQLite accepts and prints a table, or
--json / --csv. It opens the file without migrating it, so a query can never change the schema.
ngn sql --help lists the tables and their columns.
bash
ngn sql "SELECT status, COUNT(*) FROM task_runs GROUP BY status"
ngn sql "SELECT * FROM logs WHERE status = 'error' ORDER BY id DESC" --json
In a browser
@apisurf/ngnui is a separate CLI that serves a prebuilt dashboard over the same file:
bash
npx @apisurf/ngnui --db ./ngn.sqlite
Its live task editor needs a runtime to execute against, which only a running ngn run has —
pass --live http://127.0.0.1:4545 to connect the two.
Cron Patterns
ngn uses 6-field cron expressions (includes seconds):
┌────────────── second (0-59)
│ ┌──────────── minute (0-59)
│ │ ┌────────── hour (0-23)
│ │ │ ┌──────── day of month (1-31)
│ │ │ │ ┌────── month (1-12)
│ │ │ │ │ ┌──── day of week (0-6, Sun=0)
│ │ │ │ │ │
* * * * * *
Common patterns:
| Pattern | Description |
*/5 * * * * * | Every 5 seconds |
0 * * * * * | Every minute |
0 */5 * * * * | Every 5 minutes |
0 0 * * * * | Every hour |
0 0 9 * * * | Daily at 9:00 AM |
0 0 9 * * 1-5 | Weekdays at 9:00 AM |