Automating team culture: injecting dynamic reactions into CI/CD pipelines
Automating team culture: injecting dynamic reactions into CI/CD pipelines
The weekend I wasted on a boring bot
I once spent a full weekend building a perfectly engineered Slack bot that nobody used. I am talking about flawless error handling and strict TypeScript compilation. The deployment pipeline ran like clockwork. The bot sat in our main engineering channel, diligently reporting staging environment updates and pull request merges, while also tracking server memory spikes.
Within a week, I noticed something depressing in the workspace analytics. Everyone had muted the channel.
The bot was technically perfect but completely devoid of soul. It was just another noisy machine barking status codes at tired developers. We build complex automation pipelines to save time, but we often forget the human interface layer. When a deployment succeeds after a week of grueling bug fixes, getting a flat text message that says "Deployment 200 OK" does not build team culture or offer relief. It just adds to the endless scroll of text we consume daily.
The notification blindness problem
Alert fatigue is a recognized phenomenon in operations. We rarely talk about its cousin: notification blindness. When you pipe automated Jira updates and GitHub merges alongside Datadog alerts into a single stream, the human brain simply starts filtering it out as static. I realized my system lacked a necessary component for capturing human attention. It lacked unpredictability.
Maintaining a static folder of reaction images was my first thought. I hardcoded a few image URLs for success and failure states. That got stale in exactly three days. People learn the visual patterns, and the blindness returns immediately. To keep people engaged with system health, the system needed a sense of humor that evolved on its own.
Discovering the missing dependency
The answer turned out to be an external dependency I never thought I would add to a serious project. I found an API service called Jjalbot that exposes an interface for Korean reaction images and memes. Instead of hardcoding a single image for a failed build, my system could search for a keyword like "좌절" (frustration) or "엄지척" (thumbs up) and pull a random, highly relevant image to attach to the payload.
They even provide a CLI tool via npm. Running npx jjalbot search "엄지척" locally gives you immediate JSON results. This makes prototyping incredibly fast for local shell scripts or git hooks.
But integrating a playful API into a production notification pipeline requires actual system thinking. You cannot let your deployment alerts fail entirely just because a third-party meme generator went offline. You also cannot spam the API and hit rate limits every time a monorepo triggers forty simultaneous build events.
Architecting the integration
If we are going to add an external network call to our notification pipeline, we have to treat it with the exact same suspicion we apply to any microservice. The image fetch process must be asynchronous with a strict timeout. It must fall back gracefully to a static asset. Ideally, it should implement an in-memory cache to handle sudden spikes in activity.
I decided to build a robust wrapper class in TypeScript. This class uses the native fetch API to query the service. It applies a timeout race condition and caches recent responses. It also falls back to a default static image if anything goes wrong.
Let us start by defining the domain models.
// types.ts
export interface ImageSearchResult {
id: string;
imageUrl: string;
tags: string[];
source: string;
}
export interface NotificationPayload {
service: string;
status: 'success' | 'failure' | 'warning';
message: string;
}
export interface CacheEntry {
urls: string[];
timestamp: number;
}With the types established, we can build the actual client. I always wrap external calls in a timeout mechanism. Node's native fetch does not fail fast enough by default if the remote server is hanging, and we do not want to delay a critical alert waiting for a joke image to load.
// imageClient.ts
import { ImageSearchResult, CacheEntry } from './types';
export class DynamicImageClient {
private baseUrl: string;
private timeoutMs: number;
private fallbackImage: string;
private cache: Map<string, CacheEntry>;
private cacheTtlMs: number;
constructor(baseUrl = 'https://jjalbot.com/api', timeoutMs = 2000) {
this.baseUrl = baseUrl;
this.timeoutMs = timeoutMs;
this.fallbackImage = 'https://my-cdn.com/static-default.png';
this.cache = new Map();
this.cacheTtlMs = 1000 * 60 * 60; // 1 hour TTL
}
private withTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), this.timeoutMs)
)
]);
}
private getCachedUrls(keyword: string): string[] | null {
const entry = this.cache.get(keyword);
if (!entry) return null;
const isExpired = Date.now() - entry.timestamp > this.cacheTtlMs;
if (isExpired) {
this.cache.delete(keyword);
return null;
}
return entry.urls;
}
public async fetchReaction(keyword: string): Promise<string> {
const cachedUrls = this.getCachedUrls(keyword);
if (cachedUrls && cachedUrls.length > 0) {
return this.pickRandomUrl(cachedUrls);
}
const endpoint = `${this.baseUrl}/search?q=${encodeURIComponent(keyword)}`;
try {
const response = await this.withTimeout(
fetch(endpoint, {
headers: { 'Accept': 'application/json' }
})
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = (await response.json()) as ImageSearchResult[];
if (!data || data.length === 0) {
return this.fallbackImage;
}
// Extract URLs and cache the top 10 results
const limit = Math.min(data.length, 10);
const urls = data.slice(0, limit).map(item => item.imageUrl);
this.cache.set(keyword, {
urls,
timestamp: Date.now()
});
return this.pickRandomUrl(urls);
} catch (error) {
console.warn(`Failed to fetch dynamic image for keyword '${keyword}'. Using fallback.`, error);
return this.fallbackImage;
}
}
private pickRandomUrl(urls: string[]): string {
const randomIndex = Math.floor(Math.random() * urls.length);
return urls[randomIndex];
}
}This implementation handles all the edge cases. It caches responses to prevent hammering the API when multiple builds fail simultaneously. It limits the network request to two seconds. It gracefully falls back to a hosted default image if the internet weather is bad.
Wiring it into the notification pipeline
Now we have a safe, timeout-bounded client. The next step is integrating this into the actual webhook dispatcher. Let us assume we have an Express server that receives GitHub Action webhooks and forwards them to a team Discord or Slack channel.
We map the build status to specific keywords. A successful build searches for positive reinforcement. A failure looks for dramatic despair.
// notifier.ts
import { DynamicImageClient } from './imageClient';
import { NotificationPayload } from './types';
export class TeamNotifier {
private imageClient: DynamicImageClient;
private webhookUrl: string;
constructor(webhookUrl: string) {
this.imageClient = new DynamicImageClient();
this.webhookUrl = webhookUrl;
}
private determineSearchKeyword(status: NotificationPayload['status']): string {
switch (status) {
case 'success':
return '엄지척'; // thumbs up
case 'failure':
return '절망'; // despair
case 'warning':
return '눈물'; // tears
default:
return '안녕'; // hello
}
}
public async dispatch(payload: NotificationPayload): Promise<void> {
const keyword = this.determineSearchKeyword(payload.status);
const reactionUrl = await this.imageClient.fetchReaction(keyword);
const chatMessage = {
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `[${payload.service}] Status: ${payload.status.toUpperCase()}`
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: payload.message
}
},
{
type: 'image',
image_url: reactionUrl,
alt_text: 'system reaction'
}
]
};
try {
await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(chatMessage)
});
console.log(`Notification dispatched for ${payload.service}`);
} catch (error) {
console.error('Failed to dispatch notification entirely', error);
}
}
}To complete the system, we wire this into a lightweight Express server that listens for incoming CI/CD events.
// server.ts
import express from 'express';
import { TeamNotifier } from './notifier';
const app = express();
app.use(express.json());
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL || '';
const notifier = new TeamNotifier(SLACK_WEBHOOK);
app.post('/webhook/ci', async (req, res) => {
const { service, status, message } = req.body;
if (!service || !status || !message) {
return res.status(400).json({ error: 'Invalid payload schema' });
}
// Acknowledge the webhook immediately so the CI pipeline can close the connection
res.status(202).json({ status: 'Processing notification' });
// Process the image fetch and dispatch asynchronously
await notifier.dispatch({
service,
status,
message
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Notification service running on port ${PORT}`);
});Notice the architectural choice in the Express handler. We return a 202 Accepted status immediately before calling notifier.dispatch(). CI pipelines often have strict timeout limits for their webhook publishers. If we make GitHub wait for our server to query a meme API and push to Slack before returning a 200 OK, we risk the CI system marking the webhook delivery as failed. Asynchronous background processing is non-negotiable here.
Leveraging the CLI for local hooks
The API is fantastic for centralized servers, but individual developers often run local scripts. I love standardizing pre-commit or pre-push git hooks across a team. Using the provided npm CLI tool makes this trivial without writing custom network logic in bash.
You can invoke the CLI tool through Node's exec module in a local sandbox script, or directly in a bash hook.
// test-cli.js
const { exec } = require('child_process');
console.log('Fetching a victory image for local build success...');
exec('npx jjalbot search "퇴근"', (error, stdout, stderr) => {
if (error) {
console.error(`Execution error: ${error}`);
return;
}
try {
// The CLI likely outputs JSON strings if piped or captured
console.log('Result from CLI:');
console.log(stdout);
} catch (parseError) {
console.log('Failed to parse CLI output');
}
});I ended up wrapping this command in a simple bash script that fires off an OS-level notification using osascript on macOS whenever a massive local Docker build finishes. It pops up a random reaction image right in the notification center. It completely eliminated the urge to switch context to Twitter while waiting for compilations to finish.
The reality of internal tooling
Systems do not have to be sterile. Adding a bit of unpredictability to internal tools actually increases engagement. When I deployed the updated bot with the dynamic image fetching, the channel mute rate dropped to zero. Developers started checking the deployment channel just to see which ridiculous image the system would pull for the latest successful build.
We spend immense amounts of energy making our code deterministic and resilient. It must be predictable. That is exactly how software should behave. The data layer must be strict. The business logic must be tested.
But the interfaces where that software meets human beings need a different approach entirely. Humans get bored easily. They ignore repetitive data. They need novelty to stay engaged with automated systems.
By treating a meme generator API with the exact same architectural respect we give a production database, we bridge the gap between robust engineering and human reality. We implement timeouts and provide fallbacks to ensure type safety. Your pipelines remain rock solid under the hood. The people watching those pipelines can actually smile during a stressful deployment.