Ten scheduled AI agents run my agency's operations: lead research, PR outreach, reply triage, pipeline sync, inbox monitoring, daily briefings. No n8n, no framework, no queue, no Docker. Each agent is one Node file on a launchd timer, and every irreversible action passes a unit-tested guardrail before it happens. This repo is that harness, extracted clean as a template.
The numbers:
- 10 agents in production at Digiton Dynamics - 0 runtime dependencies, no build step, plain Node
- 27 unit tests on the rails, CI on Node 18/20/22
- 1 file per agent
- 5 rails on every action: kill switch, do-not-contact, allowlist, dedupe, daily cap
$ npm run demo
[INFO] DRAFT ana@oceanview-realty.pt :: allowed, not sent (mode=draft)
[INFO] BLOCK carlos@montebianco.com :: recipient not on allowlist
[INFO] BLOCK info@donotcontact.example :: recipient on DNC
[INFO] done proposed=3 allowed=1 blocked=2 performed=0 killed=false
Why the rails exist: we once ran an unguarded email bot on a timer in production. No allowlist, no daily cap, no off switch - one bad prompt or one bad data row away from mailing the wrong list at 3am. We shut it down and rebuilt every scheduled agent behind a single pure function, evaluate()
in lib/rails.js
. Nothing irreversible happens without clearing it.
Safe by default on a fresh clone: empty allowlist, stub sender, dry-run unless you opt in.
proposed action -> [ rails ] -> sent or blocked (with a reason)
git clone https://github.com/Botfather90/digiton-agent-fleet
cd digiton-agent-fleet
npm test # run the rails unit tests (zero setup)
npm run demo # watch the rails decide on sample leads (no credentials)
Prefer your own copy: click Use this template at the top of the GitHub page to start a fresh repo from this one.
The demo runs the example follow-up agent in dry-run against three sample leads and shows the rails at work: one allowed, one blocked for not being on the allowlist, one blocked by the do-not-contact list. A second worked example, jobs/example-digest.js
, posts a daily digest to a Slack-style webhook channel, a non-email action routed through the same evaluate()
so recipient, DNC, allowlist, dedupe, and daily cap all still apply. Run it with npm run demo:digest
.
Every action the agent proposes is checked by evaluate()
in lib/rails.js
, in this order, hardest stop first:
| Check | Behavior |
|---|---|
| Kill switch | touch state/KILL freezes every job instantly. Nothing runs until you rm it. |
| Recipient | Must be present, or the action is dropped. |
| DNC | Do-not-contact list (email or domain). Always wins, even over the allowlist. |
| Allowlist | An empty allowlist means nothing sends. You opt in addresses or whole domains. |
| Dedupe | A dedupeKey is never actioned twice within the retention window (30 days). History is pruned by age, not kept forever. |
| Daily cap | Stop after N actions per day. The counter resets at midnight UTC. |
evaluate()
is a pure function with no I/O, so it is fast to reason about and fully unit-tested.
The template cannot email anyone on a fresh clone. The example sender is a deliberate stub, the default allowlist is empty, and the runner is dry-run unless you pass --send
and set "mode": "send"
. You have to opt in to every one of those before a single message leaves.
A job is one file in jobs/
that exports two functions:
// jobs/my-agent.js
module.exports = {
// Return the actions you want to take. The runner does NOT trust these,
// it runs each one through the rails before anything happens.
async propose({ config, log }) {
return [
{ to: 'lead@example.com', subject: 'Hello', body: '...', dedupeKey: 'lead-2026-06' },
];
},
// Perform ONE allowed action. This is the only place that actually sends.
async send(action, { config, log }) {
// your real sender goes here: Gmail, Resend, SES, an API call, whatever
},
};
Run it: node bin/run-job.js my-agent
(dry-run) or add --send
to dispatch allowed actions.
Set your policy in fleet.config.json
(copy fleet.config.example.json
):
{
"mode": "draft",
"policy": {
"dailyCap": 30,
"allowEmails": ["vip@client.com"],
"allowDomains": ["client.com"],
"dnc": ["unsubscribed@x.com", "competitor.com"]
}
}
Your real fleet.config.json
is gitignored, so your allowlist never ends up in a commit.
Pick your platform. All three run the agent daily at 09:00 by default.
- macOS (launchd):
bash schedule/install.sh
installs a launchd job and prints how to run, inspect, and remove it. - Linux (systemd): copy the unit and timer from
schedule/systemd/
andsystemctl enable --now agent-fleet.timer
. - Anything with cron: paste the line from
schedule/crontab.example
intocrontab -e
.
lib/rails.js the guardrail (pure evaluate() + ledger persistence)
lib/log.js dated file + stdout logger
bin/run-job.js the governed runner
jobs/example-followup.js a worked example agent (stub sender, safe)
jobs/example-digest.js a daily digest to a Slack-style webhook channel (a non-email action), through the same rails
data/leads.sample.json sample data so the demo runs with no setup
data/digest.sample.json sample data for the digest example
test/rails.test.js unit tests for the rails and the cadence
test/example-digest.test.js unit tests for the digest job on the same rails
schedule/ launchd, cron, and systemd templates + installer
.github/workflows/test.yml node:test on push/PR (Node 18/20/22)
MIT. Built and maintained by Digiton Dynamics.