Email Monitoring with MailWebhook and Zabbix

SMTP checks can tell you whether a server appears healthy. They do not always tell you whether a real message made it through the path your users depend on.

This recipe uses MailWebhook as the arrival detector and Zabbix as the monitoring system. The goal is simple: send a tagged canary email, report success only when that email arrives, and let Zabbix alert when the success heartbeat goes missing.

Pattern

  1. A cron job sends a canary email through an external sender.
  2. The sender resolves MX and delivers to the mailbox under test.
  3. MailWebhook watches that mailbox.
  4. When the canary arrives, MailWebhook calls Zabbix history.push().
  5. Zabbix alerts if no OK heartbeat arrives within the expected window.

The key idea is to alert on missing success, not on every possible delivery failure.

When the canary arrives, MailWebhook calls Zabbix history.push(). Zabbix alerts only if no OK heartbeat arrives within the expected window.

The key idea is to alert on missing success, not on every possible delivery failure.

Canary tagging

Do not send a generic test email. Send a tagged canary:

Subject: MW-CANARY v=1 env=prod mailbox=mx1-prod sender=postmark id=<uuid> ts=<utc>

Each tag has a job:

MW-CANARY      route this email as a canary
env            avoid mixing prod/staging
mailbox        map the OK to the right Zabbix item
sender         identify the external sender path
id             correlate sent vs received canary
ts             keep sent time for troubleshooting or future latency checks

For the first version, Zabbix only needs the OK heartbeat. This canary:

MW-CANARY v=1 env=prod mailbox=mx1-prod sender=postmark id=7f3a9c ts=2026-05-18T12:00:00Z

maps to this Zabbix item key:

mail.canary.ok[prod,mx1-prod]

Zabbix setup

Create a host:

Host: mail-deliverability

Create one Zabbix trapper item per mailbox path:

Name: Canary OK: prod mx1-prod
Key: mail.canary.ok[prod,mx1-prod]
Type: Zabbix trapper
Type of information: Numeric unsigned

Then add a trigger expression using nodata():

nodata(/mail-deliverability/mail.canary.ok[prod,mx1-prod],10m)=1

Example tuning:

Send interval: 1 minute
Warning: nodata(...,5m)=1
Critical: nodata(...,10m)=1

For noisier paths:

Send interval: 5 minutes
Warning: nodata(...,15m)=1
Critical: nodata(...,30m)=1

This avoids flapping because one delayed email does not immediately create an incident. Zabbix only alerts when the OK heartbeat has been missing for longer than the threshold.

MailWebhook endpoint

Create a MailWebhook endpoint pointing to the Zabbix JSON-RPC API:

https://zabbix.example.com/zabbix/api_jsonrpc.php

Add a custom header:

Authorization: Bearer <ZABBIX_API_TOKEN>

MailWebhook route

Use MailWebhook route rules to match only canary emails:

{
  "to_emails": ["[email protected]"],
  "from_domains": ["your-canary-sender.example"],
  "subject_regex": [
    "^.*\bMW-CANARY\b.*\benv=[A-Za-z0-9_-]+\b.*\bmailbox=[A-Za-z0-9_-]+\b.*$"
  ]
}

MailWebhook custom JSON payload

Use map.custom_json to emit the Zabbix history.push() request. The expressions below use the MailWebhook JsonLogic-style DSL for regex.replace and cat.

{
  "pipeline": {
    "steps": [
      {
        "name": "map.custom_json",
        "args": {
          "version": "v1",
          "vars": [
            {
              "name": "env",
              "expr": {
                "regex.replace": {
                  "value": { "var": "message.subject" },
                  "pattern": "^.*\benv=([A-Za-z0-9_-]+)\b.*$",
                  "with": "\1"
                }
              }
            },
            {
              "name": "mailbox_id",
              "expr": {
                "regex.replace": {
                  "value": { "var": "message.subject" },
                  "pattern": "^.*\bmailbox=([A-Za-z0-9_-]+)\b.*$",
                  "with": "\1"
                }
              }
            },
            {
              "name": "zabbix_key",
              "expr": {
                "cat": [
                  "mail.canary.ok[",
                  { "var": "vars.env" },
                  ",",
                  { "var": "vars.mailbox_id" },
                  "]"
                ]
              }
            }
          ],
          "output": {
            "jsonrpc": "2.0",
            "method": "history.push",
            "params": [
              {
                "host": "mail-deliverability",
                "key": { "var": "vars.zabbix_key" },
                "value": 1
              }
            ],
            "id": 1
          }
        }
      }
    ]
  }
}

For the example subject above, MailWebhook sends this to Zabbix:

{
  "jsonrpc": "2.0",
  "method": "history.push",
  "params": [
    {
      "host": "mail-deliverability",
      "key": "mail.canary.ok[prod,mx1-prod]",
      "value": 1
    }
  ],
  "id": 1
}

Canary sender

Here is a minimal cron sender:

#!/usr/bin/env bash
set -euo pipefail

ENV="prod"
MAILBOX_ID="mx1-prod"
SENDER_ID="postmark"
TO="[email protected]"
FROM="[email protected]"

CANARY_ID="$(uuidgen)"
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

SUBJECT="MW-CANARY v=1 env=${ENV} mailbox=${MAILBOX_ID} sender=${SENDER_ID} id=${CANARY_ID} ts=${TS}"

sleep "$(( RANDOM % 20 ))"

printf 'deliverability canaryn' | mail 
  -s "$SUBJECT" 
  -r "$FROM" 
  "$TO"

Cron:

* * * * * /opt/mail-canary/send-canary.sh

The jitter prevents all checks from landing at the exact same second.

Operational notes

Simple start:

One mailbox path -> one Zabbix trapper item -> one nodata trigger

Add dimensions only when needed:

mail.canary.ok[prod,mx1-prod]
mail.canary.ok[prod,mx1-prod,postmark]
mail.canary.ok[prod,mx1-prod,ses]

Multi-sender checks are useful when you need to distinguish mailbox-path failures from sender-path failures.

One caveat: direct delivery to Zabbix is the simplest implementation, but Zabbix will not validate MailWebhook webhook signatures or a canary HMAC token by itself. For stricter security, put a tiny gateway in front: verify the MailWebhook signature, optionally validate a canary token, then forward history.push() to Zabbix.

🔌 The “Connect Wallet” Button Replaced My Entire Auth System

Here’s something I didn’t expect when I started building on Solana: I never wrote a signup form.

No username field. No password validation. No “confirm your email” flow. No “forgot password” page. Every piece of auth infrastructure I’d built dozens of times in Web2 — gone.

In its place: one button. “Connect Wallet.”

🧠 What a Wallet Actually Does

If you’re new to Solana, I wrote about the conceptual shift — from usernames to keypairs — in a previous post –

The short version: your identity is a cryptographic keypair, not a database row.

But for a developer, the practical implication matters more than the theory. Here’s what changes:

You don’t manage authentication. The wallet does. When a user clicks “Connect Wallet,” their browser extension (Phantom, Solflare, etc.) pops up a confirmation. They approve. Your app gets their public key. You never see the private key. You never store a password hash. You never issue a session token.

You don’t build onboarding flows. In Web2, every step of signup is a drop-off point. Email entry, email verification, profile setup, terms acceptance. On Solana, the user already has an identity before they visit your app. Your job is just to detect it.

“Logging in” is just signing a message. Your app sends a message, the wallet signs it, you verify the signature on your backend. No session cookie. No JWT. The signature is the proof.

⚠️ What You Lose

This isn’t strictly better. There are tradeoffs:

  • No password reset. If a user loses their private key, they lose access. There’s no admin tool to restore their account.
  • No account recovery. In Web2, you can email a reset link. On Solana, the keypair is the only identity. Lose it, and everything associated with that address — tokens, NFTs, program state — is inaccessible.
  • No gradual onboarding. In Web2, you can let users browse before requiring login. On Solana, the user’s address is their identity from the first interaction. There’s no “anonymous session” that converts to a registered account later.

I covered the technical details of keypair generation and storage — why extractable: true matters, how PKCS8 works — in another post –

But the developer workflow shift is what I had to actually rebuild in my head.

💻 The Code Difference

In Web2, auth looks like this:

// Signup
app.post("/signup", async (req, res) => {
  const hash = await bcrypt.hash(req.body.password, 10);
  await db.users.create({ email: req.body.email, passwordHash: hash });
  req.session.userId = user.id;
});

// Login
app.post("/login", async (req, res) => {
  const user = await db.users.findByEmail(req.body.email);
  if (!user || !(await bcrypt.compare(req.body.password, user.passwordHash))) {
    return res.status(401).send("Invalid credentials");
  }
  req.session.userId = user.id;
});

On Solana, auth looks like this:

// Connect wallet (browser side)
const { accounts } = await wallet.features["standard:connect"].connect();
const userAddress = accounts[0].address;

// That's it. The user is now "authenticated."

No password hashing. No session management. No database lookups. The wallet handles the cryptographic proof. Your app just reads the public key.

🎯 Why This Matters

The “Connect Wallet” pattern isn’t just a UI trend. It represents a fundamental shift in how identity works. In Web2, you build auth into your app. In Solana, auth is infrastructure — it lives in the wallet, not in your codebase.

That means as a developer, you don’t need to worry about password storage, session hijacking, or email verification. But you also can’t rely on support tickets or account recovery when something goes wrong.

Once I understood this tradeoff — less code, more responsibility — the “Connect Wallet” button stopped looking like a simplification and started looking like a completely different approach to identity.

Next: Transactions aren’t HTTP requests. They’re signed checks — and failed ones still cost you.

This is a submission for the 100 Days of Solana Writing Challenge, running from 15 May to 22 May.

Understanding Apache Kafka: A Beginner’s Guide to Real-time Data Streaming

Apache Kafka is a technology that is mainly used to build high-performance, real-time data pipelines and streaming applications. It handles vast quantities of data and this article aims to demystify Kafka for beginners. We will explain its core concepts and show its practical application through a real-time weather data processing example.

What is Apache Kafka?

Apache Kafka is an event streaming platform that is distributed, scalable, and fault-tolerant. Think of it as a highly efficient, persistent message queue that allows different applications to communicate by sending and receiving data streams. Kafka facilitates a publish-subscribe model where data producers send messages to a central system, and data consumers can read these messages independently.

Key Components of Apache Kafka

To understand how Kafka works, it’s essential to grasp its fundamental components:

1. Producers
Producers are client applications that publish (write) data records (messages) to Kafka topics. They are responsible for creating new data and sending it to the Kafka cluster. For instance, in our weather data example, the Python script fetching weather information from an API acts as a producer.

2. Consumers
Consumers are client applications that subscribe to (read) data records from Kafka topics. They process the data streams published by producers.

3. Brokers
Brokers are the core servers that form the Kafka cluster. Each broker is a Kafka server that stores data, handles requests from producers and consumers, and replicates data for fault tolerance.

4. Topics and Partitions
Topics are categories or feeds to which records are published. They are logical channels for organizing data streams. For example, open_weather_data would be a topic for all weather-related messages. Topics are further divided into partitions, which are ordered, immutable sequences of records.

5. Zookeeper (or Kraft in newer versions)
Historically, Kafka relied on Apache ZooKeeper for managing the cluster’s metadata. In newer versions of Kafka, Kraft has been introduced to remove the dependency on ZooKeeper, simplifying the architecture.

Building a Real-time Weather Data Processing Pipeline with Kafka

Let’s illustrate these concepts with a practical example: a real-time weather data processing pipeline using the provided Python code. This pipeline demonstrates how producers fetch data, publish it to Kafka, and consumers then process it.

Producer Code Explanation

import requests
import json
from kafka import KafkaProducer
import time
from dotenv import load_dotenv
import os

load_dotenv()

api_key = os.getenv("API_KEY")

def get_weather_data():

    cities = ["Nairobi", "Mombasa", "Kisumu", "Eldoret", "Nakuru"]

    city_list = []

    for city in cities:

        url = f"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}"

        data = requests.get(url)

        raw_data = data.json()

        city_list.append(
            {
                "city": city,
                "temperature": raw_data["main"]["temp"],
                "humidity": raw_data["main"]["humidity"],
                "description": raw_data["weather"][0]["description"],
                "last_update": raw_data["dt"]
            }
        )

    return city_list

producer = KafkaProducer(
    bootstrap_servers='localhost:9092',
    value_serializer = lambda p:  json.dumps(p).encode('utf-8')
                            )


while True:
    weather_data = get_weather_data()
    topic = 'open_weather_data'
    producer.send(topic, value=weather_data)
    print(f"Producer: {weather_data}")
    time.sleep(5)

  • The script fetches current weather data for a list of Kenyan cities from the OpenWeatherMap API.

  • It then extracts relevant weather details (city, temperature, humidity, description, last update timestamp) and returns them as a list of dictionaries.

  • We then specify the address of the brokers (localhost:9092) that the Producer will connect to.

  • value_serializer : Defines how the data sent to Kafka should be serialized. In our case, it converts the Python dictionary containing the weather data to json and then encodes it to utf-8, which is the format Kafka expects.

  • The while loop ensures we run the above steps continuously.

  • We then define a topic as, ‘open_weather_data’ and use producer.send(topic, value=weather_data) to publish the fetched weather data to the topic defined.

  • In our code, we’ve defined a pause of 5 seconds before fetching the next batch but this can be adjusted accordingly.

Consumer Code Explanation

from kafka import KafkaConsumer
import json

consumer = KafkaConsumer(
    'open_weather_data',
    bootstrap_servers='localhost:9092',
    value_deserializer = lambda m: json.loads(m.decode('utf-8')),
    auto_offset_reset='earliest'
)

for message in consumer:
    print(f"Consumer: {message.value}")

  • ‘open_weather_data’ specifies the topic from which the consumer will read messages.

  • bootstrap_servers = ‘localhost:9092’ is the same as for the producer, pointing to the Kafka broker(s).

  • value_deserializer = lambda m: json.loads(m.decode(‘utf-8’)) defines how the received data (value) from Kafka should be deserialized. It decodes the UTF-8 bytes back into a JSON string and then parses it into a Python dictionary.

  • auto_offset_reset = ‘earliest’ : ‘earliest’ means the consumer will start reading from the beginning of the topic (the earliest available offset). Other options include ‘latest’ (start from the most recent messages) or ‘none’ (throw an error if no valid offset is found).

Conclusion

Apache Kafka provides a robust and scalable solution for handling real-time data streams. By understanding its core components—producers, consumers, brokers, topics, and partitions—and seeing how they interact in a practical example like our weather data pipeline, you can begin to appreciate its power. This setup allows for efficient, decoupled communication between different parts of an application, enabling real-time data processing and analytics that are vital in today’s fast-paced digital world.

FCIS in Godot

This is a blog post in my FCIS in Godot series.

A problem I find when architecting large game development projects is the eventual tight-coupling between game logic and individual actors.

Game logic becomes tangled with scene Nodes, making it unportable.

This leads me to a mess of systems, leaving me without a library of portable scripts I can drop into any project, this is something I hope to change.

Functional Core

In this new functional game development architecture we draw a line in the sand between our functional and importantly: non-side effecting core code base and the much more granular imperative shell where we make our connection to the real world (via drawing on the screen and taking input from the user).

We are going to make sure our core game logic has zero reference or dependence on the Godot runtime what so ever. We can then make sure our “core”, core game logic is free of side-effects and made up of pure functions.

Implementation

Let’s now see how we can implement FCIS in Godot.

State Event Transformation

The functional core is made up of pure functions (systems) which take an initial, immutable, state and some event and return a new state.

The most high-level example of this transformation we could implement is;


// Core/WorldState.cs

// Some record which stores information about the current state of our game.
// With a read-only initialiser to ensure immutability.

public record WorldState 
{
    public static readonly WorldState New = new();
}

// Core/GenericEvent.cs

// Some event we can extend later.

public abstract record GenericEvent;

// Core/Simulation.cs

// The main transformation and entry point to our core game logic.
// This system will likely call transformations on other systems,
// i.e. a player-movement system, in a pipeline-like fashion.

public static WorldState Update(WorldState state, GenericEvent event) {}

Imperative Shell

This is where we make the bridge between our game logic and the game engine.

We will keep this as minimal and as ‘dumb’ as possible, as this is the code most coupled with our current project and most likely the code we will need to re-write in each new game we create.

We can simply call the functions WorldState.New and Simulation.Update to enter our game logic.

// ... some script attached to a Node

private WorldState _state = WorldState.New;

public override void _Process(double delta)
{
    if (Input.IsActionPressed("ui_up")) 
    {
        _state = Simulation.Update(_state, new GenericEvent());
    }
}

Counter Example

I can see how this still might also seem confusing and abstract, so let’s work through a very simple game.

Our game will simply display a counter on the screen (a Label Node), and if the user clicks the ↑ arrow, the counter increments, and if the user clicks the ↓ arrow the counter decrements.

Folder Structure

In a new project I will create two new folders; core/ and shell/.

Core

In core/, I will create three new scripts; CInputEvent.cs, WorldState.cs and Simulation.cs.

CInputEvent.cs

namespace Core.Events;

public abstract record CInputEvent;
public record Increment : CInputEvent;
public record Decrement : CInputEvent;

WorldState.cs

namespace Core.State;

public record WorldState(int Counter)
{
        public static readonly WorldState New = new(0);
}

Simulation.cs

using Core.Events;
using Core.State;

namespace Core;

public static class Simulation
{
        public static WorldState Update(WorldState state, CInputEvent input) => input switch
        {
                Increment => state with { Counter = state.Counter + 1 },
                Decrement => state with { Counter = state.Counter - 1 },
                _ => state
        };
}

Note the nice, modern pattern matching you can do in C# 7.0+

Shell

In our shell/ folder I’ll create one script to attach to a root Control Node in our main scene.

GameRoot.cs

using System.Collections.Immutable;
using Core;
using Core.Events;
using Core.State;
using Godot;

namespace Shell.Nodes;

public partial class GameRoot : Control
{
    private WorldState _state = WorldState.New;
    [Export]
    private Label _label = null;

    public override void _Ready()
    {
        Render();
    }

    public override void _Process(double delta)
    {
        var inputs = GatherInputs();
        if(inputs.IsEmpty) return;

        _state = inputs.Aggregate(_state, Simulation.Update);
        Render();
    }

    private static ImmutableList<CInputEvent> GatherInputs()
    {
        var events = ImmutableList.CreateBuilder<CInputEvent>();
        if (Input.IsActionJustPressed("ui_up")) events.Add(new Increment());
        if (Input.IsActionJustPressed("ui_down")) events.Add(new Decrement());
        return events.ToImmutable();
    }

    private void Render()
    {
        _label.Text = $"Counter: {_state.Counter}";
    }
}

All that is left to do is create a scene in Godot with a Label, attach the shell script.

Portability

In our example game, I now want to display a new counter with the same behaviour but in a 3D screen, and to be incremented when the user presses a different button.

In a tightly coupled implementation I would have to completely rewrite the counter’s behaviour again to cater specifically to this new scenario.

However, because our game logic is completely decoupled, the counter’s behaviour is ready to be reused where ever we might need it, and the more imperative action of detecting which button the user has pressed is trivial to implement.

GameRoot3D.cs

using System.Collections.Immutable;
using Core;
using Core.Events;
using Core.State;
using Godot;

namespace Shell.Nodes;

public partial class GameRoot3D : Node3D
{
    private WorldState _state = WorldState.New;
    [Export]
    private Label3D _label = null;

    public override void _Ready()
    {
        Render(); 
    }

    public override void _Process(double delta)
    {
        var inputs = GatherInputs();
        if(inputs.IsEmpty) return;

        _state = inputs.Aggregate(_state, Simulation.Update);
        Render();
    }

    private static ImmutableList<CInputEvent> GatherInputs()
    {
        var events = ImmutableList.CreateBuilder<CInputEvent>();
        if (Input.IsActionJustPressed("ui_enter")) events.Add(new Increment());
        return events.ToImmutable();
    }

    private void Render()
    {
        _label.Text = $"Counter: {_state.Counter}";
    }
}

Conclusion

You should be able to see how our new game architecture is helping us solve the issues of portability and extensibility by completely decoupling our game logic from whatever the current specific game project we are working on at the time.

In the above example there is clearly little time and effort saved, however if you imagine larger systems implemented in this way across an entire game the power of this architecture becomes apparent.

In the coming blog posts I will be using this framework in a real game example and document my progress and findings getting a player/enemy system up and running.

Why AI Infrastructure Code Fails After 6 Months – Even When The Demo Worked

Most AI demos fail for boring reasons.

Not because the model stopped working.

Not because the architecture was wrong.

Usually because the surrounding infrastructure was treated like temporary code.

The first version works in staging. Everyone is happy. The AI response looks good. The dashboard works. The API calls succeed.

Then 6 months later:

  • Queue workers are stuck
  • Retry loops are duplicating records
  • Context storage is inconsistent
  • Token usage exploded
  • Logs are impossible to trace
  • One vendor silently changed response formatting
  • Nobody wants to touch the integration layer anymore

We see this pattern a lot when AI systems move from experiments into permanent operation.

The problem is that most teams still build AI systems like feature launches instead of operational infrastructure.

The Demo Phase Hides Infrastructure Problems

In early development:

  • Low traffic
  • Small datasets
  • Few edge cases
  • Short prompts
  • Manual monitoring
  • One environment
  • One client
  • One model

Everything feels stable.

Then production happens.

Now the system runs continuously:

  • Thousands of requests
  • Multi-step workflows
  • External APIs timing out
  • Different client configurations
  • Long-term memory storage
  • Version drift between services
  • Human operators depending on outputs

This is where temporary architecture starts collapsing.

The Real Problem Usually Starts Around State

Most AI systems today are stateful whether teams admit it or not.

The moment you add:

  • conversation history
  • retrieval systems
  • workflow orchestration
  • memory
  • agent actions
  • async processing

you are no longer building a simple API wrapper.

You are building distributed infrastructure.

One issue we hit recently was inconsistent retrieval context across workers.

The vector database was healthy.

The embeddings were correct.

The prompts were valid.

But async jobs were reading stale state because cache invalidation timing was different between services.

The AI output looked “random” to users.

The actual issue was infrastructure consistency.

AI Failures Rarely Look Like Traditional Failures

Traditional backend failures are easier to spot:

  • 500 errors
  • crashes
  • failed queries
  • high latency

AI infrastructure failures are slower and messier.

Examples:

  • degraded answer quality
  • partial context injection
  • duplicated memory
  • token truncation
  • hallucinations caused by stale retrieval
  • silent schema mismatches
  • prompt formatting drift

The dangerous part is that systems still appear operational.

Requests succeed.

But output quality slowly degrades.

Those failures survive longer because monitoring is usually focused on uptime instead of reasoning quality.

Vendor Instability Changes Everything

A lot of teams underestimate this.

External AI providers change behavior constantly:

  • response formatting
  • tokenization
  • latency
  • rate limits
  • model quality
  • safety filtering
  • tool calling structure

If your infrastructure assumes provider consistency, production becomes fragile fast.

We started treating model providers the same way we treat unstable third-party integrations.

That means:

  • strict schema validation
  • response normalization layers
  • retry isolation
  • fallback handling
  • output sanity checks
  • version pinning where possible

Without that layer, small upstream changes leak directly into production behavior.

Long-Term Systems Need Operational Code

There is a difference between code that works and code that survives.

Operational AI systems need things most demos ignore:

Traceability

You need to answer:

  • Which prompt version generated this output?
  • Which retrieval documents were injected?
  • Which worker processed the request?
  • Which model version responded?
  • What was the token usage?
  • What changed between successful and failed runs?

Without deep tracing, debugging becomes impossible after scale.

Replayability

One thing we started building early:

Ability to replay full AI execution chains.

Not just logs.

Actual reconstruction of:

  • prompts
  • retrieval state
  • tool outputs
  • model responses
  • orchestration decisions

Because production AI bugs are hard to reproduce otherwise.

Failure Isolation

One bad external dependency should not corrupt the entire pipeline.

We now isolate:

  • embedding generation
  • retrieval
  • model execution
  • memory updates
  • workflow actions

as separate recoverable stages.

That changed system stability more than prompt optimization ever did.

The Biggest Mistake

The biggest mistake is assuming the AI model is the product.

In enterprise systems, the model becomes one component inside a much larger operational environment.

The infrastructure around it matters more over time:

  • orchestration
  • observability
  • recovery
  • consistency
  • deployment safety
  • data integrity
  • monitoring

The model can improve next month.

Broken infrastructure compounds for years.

LinkedIn vs Resume: What Should Match, What Should Differ, and Which Matters More?

Most job seekers treat their LinkedIn profile and resume like twins — nearly identical documents wearing the same clothes. I used to do the same thing, and it cost me opportunities I didn’t even know I was losing.

Here’s the thing: your resume and your LinkedIn profile have completely different jobs. Treating them like duplicates wastes the strengths of both. Here’s what I’ve learned about keeping them aligned without making them identical.

They’re Different Documents for Different Situations

Your resume is an application document. It exists to get you past an ATS and into a recruiter’s shortlist for one specific role. It should be targeted, tight, and optimized for speed reading.

Your LinkedIn profile is a discovery and credibility document. It’s designed to help people find you, understand you quickly, and feel confident enough to reach out, refer you, or pass your name along. It supports passive opportunities, referrals, and networking — not just direct applications.

This difference changes everything about how you should build each one.

What MUST Be Consistent

When people ask “should LinkedIn match my resume?”, this is the core of the answer: the facts must match, even if the framing doesn’t.

These should be identical across both:

  • Job titles (or near-equivalent titles)
  • Employment dates
  • Employer names
  • Degree, school, and graduation year
  • Your core career direction

If your resume says “Software Engineer” and your LinkedIn headline reads “Problem Solver | Builder | Tech Enthusiast,” you’re creating a credibility gap. Recruiters compare documents. Contradictions kill trust fast.

Where They Should Differ

This is where most candidates under-leverage one or both assets.

Your LinkedIn can and should include more:

  • A broader About section with personality and narrative
  • Featured media — portfolio links, project demos, presentations
  • Recommendations from managers and colleagues
  • A wider skill surface area for search discoverability
  • Posts, articles, and thought contributions

Your resume should stay tighter:

  • Only the most relevant experience for the target role
  • Fewer bullets, but stronger bullets
  • Selective keyword alignment to the job description
  • No narrative padding — just evidence and proof

The mental model: LinkedIn goes wide, resume goes deep on what’s most relevant right now.

The Summary Problem

One of the highest-leverage differences is how you handle the summary section on each.

Your resume summary should be short, role-specific, and use language close to the job description:

“Data Analyst with 3 years of experience building SQL-based reporting pipelines, Tableau dashboards, and stakeholder-facing business analysis for fintech and SaaS products.”

Your LinkedIn About section can be slightly broader and more human:

“I turn messy data into decisions that actually get used. My background is in SQL, Tableau, and business analysis — mostly in fintech and SaaS — and I’m especially interested in the gap between what data teams build and what business teams actually act on.”

Same positioning, different tone. The resume wins the screen. The LinkedIn section builds the relationship.

A Real Example That Shows Why This Matters

Let’s say you’re a Data Analyst targeting analytics roles. Your resume is crisp, targeted, and ATS-optimized. But your LinkedIn headline still says “Business Operations Specialist | Strategy | Process Improvement” from your previous role.

What happens? A recruiter sees your resume, gets interested, searches you on LinkedIn, and immediately gets confused about what you actually want to do. The doubt is small — but it’s enough to slow down the yes.

Fix: align your LinkedIn headline to match your target role family. “Data Analyst | SQL, Tableau, Dashboard Automation” is clear, searchable, and reinforces your resume instead of conflicting with it.

Which One Matters More?

The honest answer: it depends on the step.

Resume wins when you’re applying directly. LinkedIn wins when recruiters are sourcing or when someone’s checking you out after seeing your application.

Most candidates lose on the second scenario. They tailor the resume, submit, and never notice that their LinkedIn profile is outdated, vague, or tells a different story. Recruiters often check LinkedIn within minutes of seeing an interesting resume — and that check either reinforces or undermines everything the resume built.

The 5 Most Common Mistakes

1. Making them identical — you waste what each format does well.

2. Letting them tell different career stories — different emphasis is fine, different direction is not.

3. Using a vague LinkedIn headline — if your headline doesn’t make your target role obvious, discovery suffers.

4. Updating the resume but leaving LinkedIn stale — recruiters check LinkedIn right after they see applications.

5. Fixing LinkedIn aesthetics before fixing resume quality — if the resume is weak, a polished LinkedIn won’t save a direct application.

What to Fix First

If you’re applying actively this week, fix the resume first. If you want referrals, recruiter outreach, or passive opportunities, fix LinkedIn first.

For most people in an active job search, the best order is:

  1. Fix the resume baseline
  2. Tailor the resume to the job description
  3. Align your LinkedIn headline and About section to the same direction

The goal isn’t for them to say the same things. The goal is for them to reinforce the same story — so that anyone who sees both walks away with a clear, consistent picture of who you are and what you want to do next.

Read the full article here

Been using ManyOffer to sharpen my own answers — if you want AI mock interviews with real LP feedback, they have a deal running through July worth checking out: Claim 1 free month here

How I Built a WiFi Motion Detection App Using RSSI in Flutter

WiFi signals are everywhere around us.

But most people only think of WiFi as internet connectivity.

What many don’t realize is that WiFi signals can also be used for experimental human sensing — including motion detection, presence estimation, breathing monitoring, and activity analysis.

Recently, I started building an experimental Flutter application called:

SenseWave: WiFi Motion Detector

The app explores how RSSI (Received Signal Strength Indicator) fluctuations can be analyzed in real time to estimate environmental changes caused by human movement.

This project combines:

  • Flutter
  • Android native WiFi scanning
  • RSSI signal analysis
  • Realtime visualization
  • Signal processing
  • Motion analytics

without using:

  • Camera
  • Microphone
  • Bluetooth
  • External sensors

What Is RSSI?

RSSI stands for:

Received Signal Strength Indicator

Every WiFi packet received by your device has a signal strength value.

Example:

-45 dBm → Strong signal
-72 dBm → Weak signal

When humans move inside a room, they affect:

  • signal reflections
  • attenuation
  • multipath propagation
  • interference patterns

These small fluctuations can be analyzed to estimate motion and environmental activity.

What My App Can Detect

The app is experimental, but currently focuses on:

1. Presence Detection

Estimate whether someone is inside a room by analyzing RSSI variance and signal instability.

Use cases:

  • smart home occupancy
  • room activity monitoring
  • automation triggers

2. Motion Detection

Human movement causes rapid RSSI fluctuations.

The app analyzes:

  • variance
  • signal spikes
  • temporal instability

to estimate:

  • movement intensity
  • room activity level
  • motion events

3. Approximate Position Heatmap

Using RSSI changes from nearby access points, the app can generate an approximate activity heatmap.

Important:
This is NOT precise indoor positioning.

It is an experimental approximation based on signal disturbance.

4. Breathing Detection (Experimental)

Breathing creates micro signal fluctuations.

Using:

  • FFT analysis
  • low-pass filtering
  • periodicity detection

the app attempts to estimate breathing rate under controlled conditions.

Best results:

  • single person
  • short distance
  • minimal interference

5. Sleep Monitoring

The app can also analyze:

  • breathing stability
  • nighttime motion
  • movement interruptions

to create experimental sleep activity analytics.

6. Walking Pattern Recognition

Walking generates periodic RSSI peaks.

By analyzing:

  • frequency patterns
  • step periodicity
  • motion rhythm

the app estimates:

  • walking activity
  • movement intensity
  • approximate step frequency

Signal Processing

RSSI data is noisy.

To improve stability, I use:

  • moving average filters
  • FFT
  • Kalman filtering
  • variance analysis
  • peak detection

These help extract meaningful patterns from unstable WiFi signals.

Why This Technology Is Interesting

WiFi sensing could eventually enable:

  • smarter homes
  • contactless monitoring
  • occupancy automation
  • low-cost environmental sensing

without requiring cameras or wearable devices.

The combination of:

  • RF sensing
  • mobile apps
  • realtime signal processing
  • machine learning

opens many exciting possibilities.

Final Thoughts

Building SenseWave has been one of the most interesting Flutter projects I’ve worked on.

It combines:

  • mobile engineering
  • RF sensing
  • signal processing
  • realtime visualization
  • experimental AI concepts

all inside a Flutter application.

WiFi signals contain much more information than most people realize.

And we are only beginning to explore what’s possible.

Follow My Project

App:

SenseWave: WiFi Motion Detector


Exploring realtime RSSI-based human sensing using Flutter and Android.

What is Infrastructure as Code and Why It’s Transforming DevOps

I’ve been learning cloud technologies for a while, but there’s always been one thing that slows you down when managing infrastructure manually. Mistakes, inconsistencies, and time wasted configuring servers. That’s where Infrastructure as Code (IaC) comes in. For the next 30 days, I’ll be diving into Terraform as part of the 30-day Terraform challenge, and I want to share what I learn along the way.

What is Infrastructure as Code?

Infrastructure as Code is simply managing and provisioning your infrastructure through code rather than manual configuration. Instead of clicking around in the AWS console to create servers, networks, or databases, you write code that defines what you want.

The big advantage is consistency. Every time you run your code, you get the same environment. No more missing security settings, misconfigured instances, or forgotten network rules. Everything is repeatable, version-controlled, and reviewable.

Declarative vs Imperative Approaches

Imperative: You give step-by-step instructions. For example, “create a server, install software, configure settings.”

Declarative: You declare the final state you want, and the system figures out how to get there. For example, “I want a server with NGINX installed and this network configuration.”

Write on Medium
Terraform is declarative, which means I focus on describing what I need, and it handles all the steps behind the scenes. This makes deployments faster, safer, and easier to scale.

Why Terraform is worth learning

Terraform is cloud-agnostic, widely used, and integrates well with AWS, Azure, GCP, and more. Learning Terraform means I can confidently build infrastructure across different cloud platforms. It also makes it easy to collaborate with other engineers, because the infrastructure is just code.

For someone like me who wants to grow in my devops/cloud engineering career, mastering Terraform is a clear advantage. It’s the skill that lets me move from experimenting to delivering real solutions for businesses.

My goals for this 30-day challenge is to build tangible skills I can showcase. I want to:

  • Understand Terraform deeply, not just superficially
  • Build practical projects I can showcase on GitHub and portfolio
  • Learn how to manage infrastructure efficiently on AWS
  • Share my journey so others can follow along and maybe learn a few things from my mistakes

Conclusion

Today was all about setting up my environment and understanding the foundations. The next days will be about writing actual Terraform code, experimenting with AWS resources, and turning ideas into real infrastructure.

I’m excited to continue, and I’m looking forward to sharing progress, lessons, and challenges along the way. If you’re curious about Terraform or IaC, stay tuned.

Build personalized email campaigns per customer

This guide walks through a workflow I used recently i.e. connect Orshot to Cursor, turn a reference image into a parameterized template, render a unique hero image per customer, and send it through your email provider. You stay in the editor for design and exploration and production sends use the API the same way any other integration would.

By the end of this guide, you will have a studio template with named parameters (customer_name, headline, hero_image, and so on), a render call that returns a hosted image URL per row of customer data, and a small script that combines that URL with an email send.

Prerequisites

  • An Orshot account
  • A Cursor setup
  • Whatever you already use for transactional email (Resend, AutoSend, etc.). A CSV or database of customers is enough for the last step.

What I was actually trying to do

Before the steps, it helps to be explicit about the loop, because each section maps to one part of it.

You start with a reference image (a newsletter you liked, a competitor layout, a crop from a mood board). In Cursor you ask the agent to build something similar in Orshot and to mark which layers should change per recipient. Those marks become API parameters. For each customer you pass different values into the same template, get back an image URL, and embed that URL in the email body.

Getting the MCP connected

I love being in Cursor all day and so I set up Orshot’s MCP server to avoid interacting with the console manually for updation, deletion and other functions. These are the steps I followed:

  • Sign in at orshot.com.
  • Go to Generate → Agents & MCP → Cursor → Install in Cursor. That adds the Orshot MCP server to Cursor’s configuration.
  • In Cursor, click “Connect” the MCP server when prompted, then complete authorization in the Orshot dashboard in the browser tab that opens.

  • Verify the install: open Cursor’s MCP settings and confirm tools are listed, including orshot_create_template_design, orshot_generate_image, orshot_batch_render, and orshot_get_studio_template_modifications, etc.

Turning a reference image into a template

You do not need to open Orshot Studio first. The flow below is what worked for me – reference in chat, create a simple layout, refine, then parameterize.

Step 1. Bring the reference into Cursor

Paste or attach your inspiration image in the chat. In the same message, describe the layout and the output size. Email heroes are often around 600px wide so pick a width and height you will keep for the whole campaign to avoid re-rendering at multiple sizes later.

The prompt that works for me always:

Here is a reference for an email hero. Build an Orshot template in the same spirit. Canvas 600×400. Keep the first version simple — background, headline, image area, CTA only.

Step 2. Let the agent create, refine and parameterize

The agent will typically call orshot_create_template_design for a first pass, then orshot_update_template_design for spacing, type, and polish. Asking for a perfect layout in a single call often produces a muddier result than create-then-refine so treat the second pass as a normal step.

Here’s a prompt that worked for me:

Mark headline, hero_image, customer_name, and cta_label as parameters. List all modification keys for this template and give me the template ID.

Once you are through with the changes, look at the parameters summarized by Cursor to ensure that it’s the way you want them to be for your template.

Step 3. Render a test image

Here’a a prompt that allows me to render my template for custom values:

Render template [ID] with customer_name “Alex”, headline “Hi, Alex”, hero_image [public URL], cta_label “View account”. Return a PNG URL.

Open the URL in a browser. If the layout is wrong, iterate in chat (orshot_patch_template_elements for small text or style tweaks, or another orshot_update_template_design for bigger changes). The template also appears in Orshot Studio if you want to adjust pixels by hand.

Once the template exists, the rest is mostly data

After the design is stable, each send is the same operation with different modifications. Keys must match the parameterId values from the previous section.

For dozens or hundreds of customers, loop in your own code against the REST API instead of pushing everything through chat. Keep the same modifications object per row and add a queue, retries, and logging as you would for any outbound job.

Step 1. Prepare customer data

Each row needs at least an email address and the values for your template parameters (name, plan, image URL, etc.). A CSV, database export, or CRM webhook all work as long as you can map columns to modification keys.

Step 2. Render, then send

The pattern is always to render → image URL → HTML body → send. Example with the Node SDK (swap in your email client):

import { Orshot } from "orshot";

const orshot = new Orshot(process.env.ORSHOT_API_KEY);

const TEMPLATE_ID = 12345;

type Customer = {
  email: string;
  name: string;
  plan: string;
  heroUrl: string;
};

async function sendPersonalizedHero(customer: Customer) {
  const { url: imageUrl } = await orshot.renderFromTemplate({
    templateId: TEMPLATE_ID,
    modifications: {
      customer_name: customer.name,
      headline: `${customer.name}, your ${customer.plan} summary`,
      hero_image: customer.heroUrl,
      cta_label: "View dashboard",
    },
    responseType: "url",
    responseFormat: "png"
  });

  await emailProvider.send({
    to: customer.email,
    subject: `Your ${customer.plan} update`,
    html: `
      <p>Hi ${customer.name},</p>
      <img src="${imageUrl}" width="600" alt="" style="display:block;max-width:100%;" />
      <p><a href="https://app.example.com">Open your account</a></p>
    `,
  });
}

Step 3. Verify end to end

Run one real row, pull name and plan from your spreadsheet, render, send to your own inbox, and confirm the image loads in Gmail (or whatever client your audience uses).

What I would change if I did it again

These are not requirements, but they would have saved me time on a second run.

I would decide which fields are dynamic before the layout is final, so parameter names never diverge from what I expect. I would pick canvas dimensions once and avoid re-rendering at multiple scales unless a second channel really needs it.

For work where brand fidelity is non-negotiable, you will still want a designer in the loop!

Ending Thoughts

What helped me most was dropping the repetitive export step of settling the layout, marking what should change (name, headline, image, CTA), and after that you only pass in different values and paste the URL into the message, same structure every time without opening a design file for each person. You do not need to be a designer for that part either, you can describe what you want in plain language and work from a screenshot you like, the agent and MCP handle the layout work well enough for onboarding emails, renewals, and other campaigns where “good enough and consistent” beats hand-polishing every asset.

Code Tracker AI A Developer‑First Productivity Tool (Join the Beta Waitlist)

If you’ve ever wished your IDE could quietly help you understand your coding patterns, reduce context switching, and surface insights without spying on you or slowing you down, you’re exactly who I built this for.

I’m a solo founder and engineer, and after months of building, testing, and refining, I’m opening the beta waitlist for Code Tracker AI — a privacy‑first JetBrains plugin designed to help developers work smarter, not harder.

🧠 What Code Tracker AI Does
Code Tracker AI runs inside your IDE and gives you:

Real‑time coding insights without sending your code to external servers

Pattern detection to help you understand how you work

Lightweight analytics that stay local and respect your privacy

AI‑powered summaries of your coding sessions

Zero‑setup onboarding — install, code, and get insights instantly

This isn’t another “productivity dashboard.”
It’s a tool built by a developer, for developers who want clarity without surveillance.

🔒 Privacy First, Always
No tracking scripts.
No hidden data collection.
No selling developer behavior.
Your code stays yours — period.

🧪 Beta Testers Wanted
I’m opening the beta waitlist for developers who want early access and are willing to give feedback that shapes the final product.

If you want to be part of the first wave:

👉 Join the waitlist: https://codetrackerai.com

👉 Be part of building a tool that actually respects developers

💬 Why I’m Building This
Developer tools shouldn’t feel like corporate monitoring software.
They should empower us, not watch us.

Code Tracker AI is my attempt to fix that — and I’d love your help making it even better.

If you’re curious, have ideas, or want to collaborate, drop a comment.
Let’s build something developers actually want to use.

For developers & made by a developer

Ramona (Mona) Hidalgo