DEV Community

Cover image for The AS/400 Can't Send a Slack Message. I Made It.
Mychel Garzon
Mychel Garzon

Posted on

The AS/400 Can't Send a Slack Message. I Made It.

IBM i is remarkably reliable. That reliability is also the problem. Because nothing breaks often, nobody builds proper alerting. Then something does break, nobody notices for three hours, and you're explaining to a client why their invoices didn't go out.

The first time I tried to connect to one of these systems from a modern Node.js service, the connection just hung. No error, no timeout, nothing. Turns out I had the wrong port and the IBM i firewall was silently dropping packets. That took longer to figure out than I'd like to admit.

The system has no native webhook support. No push notifications. No modern alerting primitives. What it has is DB2, SFTP, message queues, and job logs. That's enough.


The Architecture

The idea is simple: a small TypeScript service polls the IBM i system on a schedule, checks for failure conditions, and posts to an n8n webhook when it finds one. n8n handles the routing, the notification, and the audit log.

TypeScript Poller (runs every 60s)
  → Connect to IBM i via DB2
  → Query job logs / message queues / custom alert table
  → If condition met → POST to n8n webhook
      → n8n routes by severity
          → [critical] → PagerDuty + Slack
          → [warning]  → Slack only
          → [info]     → Log to sheet
Enter fullscreen mode Exit fullscreen mode

The TypeScript Service

Install the dependencies:

npm install node-jt400 node-cron axios dotenv
npm install -D typescript ts-node @types/node
Enter fullscreen mode Exit fullscreen mode

The core poller:

import * as dotenv from 'dotenv';
dotenv.config();

// eslint-disable-next-line @typescript-eslint/no-var-requires
const jt400 = require('node-jt400');

const pool = jt400.pool({
  host: process.env.IBM_HOST!,
  user: process.env.IBM_USER!,
  password: process.env.IBM_PASSWORD!,
});

interface AlertPayload {
  severity: 'critical' | 'warning' | 'info';
  jobName: string;
  message: string;
  timestamp: string;
}

async function checkJobQueue(): Promise<AlertPayload[]> {
  const rows = await pool.query(`
    SELECT MESSAGE_TEXT, MESSAGE_TYPE, FROM_PROGRAM, MESSAGE_TIMESTAMP
    FROM QSYS2.MESSAGE_QUEUE_INFO
    WHERE MESSAGE_QUEUE_NAME = 'QSYSOPR'
      AND MESSAGE_TYPE IN ('INQUIRY', 'DIAGNOSTIC')
      AND MESSAGE_TIMESTAMP > CURRENT_TIMESTAMP - 1 MINUTES
  `);

  return rows.map((row: any) => ({
    severity: row.MESSAGE_TYPE === 'INQUIRY' ? 'critical' : 'warning',
    jobName: row.FROM_PROGRAM ?? 'SYSTEM',
    message: row.MESSAGE_TEXT,
    timestamp: new Date().toISOString(),
  }));
}

// seen is in-memory so it resets on restart — good enough for most cases,
// but write it to DB2 if you need persistence across deploys
const seen = new Set<string>();

async function sendAlert(alert: AlertPayload) {
  const id = `${alert.jobName}-${alert.message}`;
  if (seen.has(id)) return;
  seen.add(id);
  await axios.post(process.env.N8N_WEBHOOK_URL!, alert);
}

cron.schedule('* * * * *', async () => {
  try {
    const alerts = await checkJobQueue();
    for (const alert of alerts) {
      await sendAlert(alert);
    }
  } catch (err) {
    console.error('Poller error:', err);
  }
});

console.log('IBM i alert poller running...');
Enter fullscreen mode Exit fullscreen mode

Before you ship this

QSYS2.MESSAGE_QUEUE_INFO is your friend. It exposes the system operator message queue without needing to write RPGLE. You can filter by message type, timestamp, and severity right in SQL. Most IBM i shops already use QSYSOPR for critical system messages so the signal-to-noise ratio is decent.

Deduplication matters more than you'd think. A single job failure can generate a dozen messages in under a minute. The seen Set in the code above handles this for most cases, but if you're running the poller across multiple instances or need history across restarts, write a seen-alerts table to DB2 instead.

node-jt400 uses JDBC under the hood via java-bridge, which means you need a JDK installed on whatever machine is running the TypeScript service — not just a JRE. And JAVA_HOME must be set at install time or the build fails with a confusing jni.h not found error. On macOS:

export JAVA_HOME=$(/usr/libexec/java_home)
npm install node-jt400
Enter fullscreen mode Exit fullscreen mode

On Linux:

export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
npm install node-jt400
Enter fullscreen mode Exit fullscreen mode

Add it to your .bashrc or systemd service env so it survives restarts. This is the most likely failure point on a fresh machine — put it in your setup docs before someone else tries to deploy it and spends 40 minutes confused.


The n8n side

Create a webhook node as the trigger. Add a Switch node routing by severity. Wire critical to a Slack node and whatever paging tool you use. Wire warning to Slack only. Wire everything to a Google Sheets or Airtable log node so you have a history.

The whole n8n workflow takes about 15 minutes to build once the TypeScript service is posting clean JSON. The part that takes longer is deciding who actually gets paged at 2am. That's a people problem, not a code problem.


What this unlocks

Once the poller is running you can extend the query to check anything DB2 can see: failed batch jobs, stuck SFTP transfers, records in an error table your CL programs write to, job queues that have been idle too long. The alerting logic lives in SQL, which means an IBM i developer can maintain it without touching TypeScript.


All code in this post was validated against a live IBM i system on pub400.com. The SQL query, column names, and connection setup are confirmed working on Node.js v22 with node-jt400.


Top comments (0)