I Built a 592-Entry Herb-Drug Interaction Database — Here’s What the Data Reveals

Last year I started building a free herb-drug interaction checker for Spanish-speaking users in Latin America. What began as a simple lookup table turned into a 592-entry database with severity classifications and PubMed citations.

Here’s what the data looks like — and what surprised me.

The Database

592 interactions between 204 medicinal herbs and pharmaceutical drugs, each classified by severity:

Severity Count % What it means
Alta (High) 84 14.2% Potentially life-threatening. Avoid combination.
Moderada (Moderate) 288 48.6% Clinically significant. Monitor closely.
Baja (Low) 220 37.2% Minor effect. Usually safe with awareness.

Every entry includes a mechanism of action and links to published evidence. No “trust me bro” — actual pharmacological reasoning.

The Dangerous Herbs (by interaction count)

Some herbs interact with everything. Here are the top 10 by number of known drug interactions:

Hypericum (St. John's Wort)  → 26 interactions
Curcuma (Turmeric)           → 16 interactions
Ginkgo biloba                → 11 interactions
Kava                         → 11 interactions
Valerian                     → 10 interactions
Green Tea                    → 10 interactions
Garlic (medicinal doses)     →  9 interactions
Berberine                    →  9 interactions
Ashwagandha                  →  9 interactions
Cannabis                     →  9 interactions

St. John’s Wort is the undisputed champion of herb-drug interactions. It induces CYP3A4 and P-glycoprotein — the same enzyme system that metabolizes ~50% of all drugs. Taking St. John’s Wort with immunosuppressants, HIV antiretrovirals, or oral contraceptives can reduce drug levels to sub-therapeutic concentrations.

This isn’t theoretical. There are documented cases of organ transplant rejection caused by St. John’s Wort.

The Surprising Findings

1. Turmeric is not as safe as Instagram thinks

Curcumin has 16 known drug interactions, including with anticoagulants (increased bleeding risk) and antidiabetic drugs (hypoglycemia risk). The “golden latte” crowd doesn’t usually mention this.

At supplemental doses (500-2000mg curcumin), the interaction potential is clinically significant. At culinary doses (a pinch of turmeric in food), it’s generally fine.

2. “Natural” doesn’t mean “no interactions”

48.6% of interactions in our database are classified as moderate — meaning they can alter drug efficacy or cause adverse effects that need medical attention. The myth that herbs are harmless because they’re “natural” is one of the most dangerous misconceptions in health.

3. The most dangerous combinations involve common drugs

Warfarin (blood thinner) and metformin (diabetes) appear in more high-severity interactions than any other drugs. If you’re on either of these and taking herbal supplements — please check.

The Technical Implementation

The interaction checker runs as a single HTML file with zero dependencies. No React, no build step, no API calls. The entire 592-entry database is embedded as a JavaScript array:

const INTERACTIONS = [
  {
    herb: "hypericum",
    herb_name: "Hipérico (St. John's Wort)",
    drug_class: "Antidepresivos ISRS",
    severity: "alta",
    mechanism: "Potenciación serotoninérgica → riesgo de síndrome serotoninérgico",
    evidence: "Múltiples reportes de caso + guías clínicas (EMA, FDA)"
  },
  // ... 591 more entries
];

Users type a herb or drug name, and the client-side search returns matching interactions instantly. No server, no latency, no privacy concerns (nothing leaves the browser).

Why a single HTML file?

  1. Deployable anywhere. GitHub Pages, S3, Cloudflare Pages — drop a file, done.
  2. Embeddable. Other health sites can iframe it.
  3. Offline-capable. Works without internet after first load.
  4. Zero maintenance. No servers to maintain, no APIs to keep alive.

For a tool that people might use in a pharmacy or rural clinic with spotty internet, this matters.

Architecture for Embeddability

We designed the tool to be embeddable from day one:

<iframe
  src="https://botanicaandina.com/herramientas/interacciones/"
  width="100%"
  height="700"
  frameborder="0"
  title="Verificador de Interacciones Hierbas-Medicamentos">
</iframe>

Any health blog or pharmacy website can embed the checker. Each embed is a potential backlink and a real service to their users.

What I’d Do Differently

  1. Start with structured data. I built the HTML first and embedded data as JS. Should have started with a clean JSON/CSV that multiple frontends can consume.

  2. Add PubMed DOIs from the start. Some early entries have “EMA guideline” as evidence instead of specific DOI links. Retrofitting DOIs is tedious.

  3. Build the severity classification rubric first. The difference between “alta” and “moderada” was subjective for the first 100 entries. I later formalized it, but some early entries need re-evaluation.

Try It

The tool is free and open: botanicaandina.com/herramientas/interacciones/

It’s in Spanish (targeting LATAM users who have even fewer resources for this kind of information), but the data structure and approach work for any language.

If you’re building health tools — the single-file, zero-dependency approach works surprisingly well for reference databases. Users don’t need a loading spinner to look up whether turmeric interacts with their blood thinner.

This is part of Botánica Andina, a project building evidence-based tools for medicinal plant information in Latin America. We also have a plant quiz, caffeine calculator, and supplement stack checker.

Every PDF Tool You Need, Running 100% in Your Browser

I got sick of PDF tools that require accounts, plaster watermarks on everything, or silently upload your files to some server in who-knows-where. So I built a full suite of PDF tools that run entirely client-side. No uploads. No accounts. No watermarks. Your files never leave your browser tab.

Here’s what’s in the toolkit and how each one works.

Merge PDFs

The most common thing people need. Drop in multiple PDFs, reorder them if you want, hit merge, get one file back.

Under the hood this uses pdf-lib to copy pages from each source document into a new one:

import { PDFDocument } from 'pdf-lib';

async function mergePDFs(files) {
  const merged = await PDFDocument.create();

  for (const file of files) {
    const bytes = await file.arrayBuffer();
    const doc = await PDFDocument.load(bytes);
    const pages = await merged.copyPages(doc, doc.getPageIndices());
    pages.forEach(page => merged.addPage(page));
  }

  return await merged.save();
}

The copyPages method is the key — it brings over not just the page content but also fonts, images, and annotations embedded in those pages. The output is a proper PDF, not some stitched-together hack.

I use this weekly to combine invoices into single files for accounting. Handles 50+ files without breaking a sweat.

Try it: devtools-site-delta.vercel.app/merge-pdf

Split PDF

The reverse operation. Got a 200-page PDF and only need pages 15-30? Or want to extract every page as its own file?

You pick the page ranges and it pulls them out:

async function splitPDF(file, ranges) {
  const bytes = await file.arrayBuffer();
  const source = await PDFDocument.load(bytes);
  const results = [];

  for (const range of ranges) {
    const newDoc = await PDFDocument.create();
    const indices = [];
    for (let i = range.start - 1; i < range.end; i++) {
      indices.push(i);
    }
    const pages = await newDoc.copyPages(source, indices);
    pages.forEach(page => newDoc.addPage(page));
    results.push(await newDoc.save());
  }

  return results;
}

It supports overlapping ranges too, so you can pull pages 1-10 and pages 5-15 as separate files in one go.

Try it: devtools-site-delta.vercel.app/split-pdf

Compress PDF

PDF compression in the browser is tricky. You can’t do the same aggressive recompression that server-side tools like Ghostscript pull off. But there’s still a lot of low-hanging fruit.

The approach: load the PDF, re-save it with pdf-lib‘s built-in optimization. This strips out redundant objects, deduplicates resources, and cleans up the cross-reference table. For PDFs exported from tools like Word or Google Docs that leave a lot of garbage in the file, this alone can cut size by 20-40%.

For image-heavy PDFs, the tool optionally renders pages to Canvas and rebuilds the PDF with compressed JPEG images. Lossy, sure, but when you’re trying to get a 50MB scan under an email attachment limit, it does the job.

Try it: devtools-site-delta.vercel.app/compress-pdf

PDF to Images

Need to turn PDF pages into PNGs? This one uses pdf.js (Mozilla’s PDF renderer) to render each page onto a Canvas, then exports as PNG or JPEG:

import * as pdfjsLib from 'pdfjs-dist';

async function pdfToImages(file, format = 'png', scale = 2) {
  const bytes = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
  const images = [];

  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i);
    const viewport = page.getViewport({ scale });

    const canvas = document.createElement('canvas');
    canvas.width = viewport.width;
    canvas.height = viewport.height;

    await page.render({
      canvasContext: canvas.getContext('2d'),
      viewport
    }).promise;

    const blob = await new Promise(resolve =>
      canvas.toBlob(resolve, `image/${format}`, 0.92)
    );
    images.push(blob);
  }

  return images;
}

The scale parameter controls output resolution. Scale 1 gives you roughly 72 DPI (matches PDF points), scale 2 gives 144 DPI which is good for most uses, and scale 3 is high-res if you need print quality.

Try it: devtools-site-delta.vercel.app/pdf-to-image

Image to PDF

The reverse — throw a bunch of images in and get a PDF. Supports PNG, JPEG, WebP, and basically any format your browser can render. You pick the page size (A4, Letter, or auto-fit), set margins, and it handles the layout.

WebP doesn’t work natively with pdf-lib, so the tool uses Canvas as a transcoding step — render the WebP to Canvas, export as PNG, then embed. The user never notices.

This one is great for converting photo sets into shareable documents, or turning a stack of scanned pages into a single file.

Try it: devtools-site-delta.vercel.app/image-to-pdf

Why 100% Client-Side Matters

Let me be blunt about this: most online PDF tools upload your files to their servers. Read their privacy policies (if they even have one). Your contracts, tax documents, medical records, and personal photos are sitting on someone else’s infrastructure.

With client-side tools, the processing happens in your browser’s JavaScript engine. The files exist as ArrayBuffer objects in memory. When you close the tab, they’re gone. There’s no network request carrying your data anywhere.

You can verify this yourself — open DevTools, go to the Network tab, and watch. Nothing gets sent out.

Performance Notes

Browser-based PDF processing is surprisingly fast. Merging 20 PDFs takes about 2 seconds on a modern machine. Splitting is nearly instant. PDF-to-image rendering depends on page complexity, but most documents convert in under a second per page.

The main limitation is memory. Browsers typically cap tab memory at 1-4 GB. For most workflows (documents under 100 pages, images under 10 MB each), you won’t hit that ceiling. If you’re trying to merge 500 high-res scanned documents, yeah, you might want a desktop tool.

For the other 99% of cases, these work great.

All the PDF Tools

Here’s the full list in one place:

All free, no accounts, no watermarks. They’re part of a larger collection of 200+ developer tools I maintain at devtools-site-delta.vercel.app. Everything runs in the browser.

If you find a bug or want a feature, I’m around.

I Made a Free Photo Editor, Meme Generator, and Background Remover — All Client-Side

I’ve been on a kick lately building browser-based tools that don’t upload your stuff anywhere. After doing PDF tools and converters, I figured image editing was the obvious next target. So I built three things: a photo editor, a meme generator, and a background remover. All running on Canvas API, all client-side, all free.

Here’s what I learned about pushing Canvas to its limits.

The Photo Editor: CSS Filters Meet Canvas

Browsers have CSS filters — brightness(), contrast(), saturate(), blur(), etc. They’re great for previewing edits, but if you want to actually export an edited image, you need to apply those filters at the pixel level using Canvas.

The nice thing is that the Canvas 2D context supports the same filter property as CSS:

function applyFilters(sourceCanvas, filters) {
  const output = document.createElement('canvas');
  output.width = sourceCanvas.width;
  output.height = sourceCanvas.height;
  const ctx = output.getContext('2d');

  // Build the CSS filter string
  const filterStr = [
    `brightness(${filters.brightness}%)`,
    `contrast(${filters.contrast}%)`,
    `saturate(${filters.saturation}%)`,
    `hue-rotate(${filters.hueRotate}deg)`,
    `blur(${filters.blur}px)`,
    `sepia(${filters.sepia}%)`,
    `grayscale(${filters.grayscale}%)`
  ].join(' ');

  ctx.filter = filterStr;
  ctx.drawImage(sourceCanvas, 0, 0);

  return output;
}

This works great for standard adjustments. But for more advanced stuff — like selective color adjustments or vignettes — you need to go deeper with getImageData and manipulate pixels directly.

Here’s a vignette effect that darkens the edges:

function applyVignette(canvas, intensity = 0.5) {
  const ctx = canvas.getContext('2d');
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;

  const cx = canvas.width / 2;
  const cy = canvas.height / 2;
  const maxDist = Math.sqrt(cx * cx + cy * cy);

  for (let y = 0; y < canvas.height; y++) {
    for (let x = 0; x < canvas.width; x++) {
      const i = (y * canvas.width + x) * 4;
      const dx = x - cx;
      const dy = y - cy;
      const dist = Math.sqrt(dx * dx + dy * dy) / maxDist;

      const darken = 1 - (dist * dist * intensity);
      data[i] *= darken;     // R
      data[i + 1] *= darken; // G
      data[i + 2] *= darken; // B
    }
  }

  ctx.putImageData(imageData, 0, 0);
}

The key insight: dist * dist gives you a smooth falloff from center to edge. Linear distance would look harsh. Squaring it makes the darkening ramp up gradually, which looks way more natural.

The Meme Generator: Text Rendering on Canvas

Meme generation sounds trivial — slap some text on an image, right? But getting the text to look like an actual meme took more work than I expected.

The classic meme font is Impact, white with black outline. The outline is the tricky part. Canvas has strokeText and fillText, and you need both:

function drawMemeText(ctx, text, x, y, maxWidth, fontSize) {
  ctx.font = `bold ${fontSize}px Impact, Arial Black, sans-serif`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'top';

  // Word wrap
  const lines = wrapText(ctx, text, maxWidth);
  const lineHeight = fontSize * 1.1;

  lines.forEach((line, i) => {
    const ly = y + i * lineHeight;

    // Black outline — draw it thick
    ctx.lineWidth = fontSize / 12;
    ctx.strokeStyle = '#000000';
    ctx.lineJoin = 'round';
    ctx.miterLimit = 2;
    ctx.strokeText(line, x, ly);

    // White fill on top
    ctx.fillStyle = '#FFFFFF';
    ctx.fillText(line, x, ly);
  });
}

function wrapText(ctx, text, maxWidth) {
  const words = text.split(' ');
  const lines = [];
  let current = '';

  words.forEach(word => {
    const test = current ? current + ' ' + word : word;
    if (ctx.measureText(test).width > maxWidth && current) {
      lines.push(current);
      current = word;
    } else {
      current = test;
    }
  });
  if (current) lines.push(current);
  return lines;
}

Two things that make this look right:

  1. lineJoin = 'round' — Without this, the stroke corners on letters like W and M look spiky and weird.
  2. Stroke before fill — If you fill first and stroke second, the outline covers the white text. Stroke first, then fill on top so the white sits cleanly inside the outline.

I also added auto-sizing. If the text is too long, the font size shrinks until it fits. Nobody wants to manually pick font sizes on a meme generator.

function autoSizeFont(ctx, text, maxWidth, startSize = 72, minSize = 24) {
  for (let size = startSize; size >= minSize; size -= 2) {
    ctx.font = `bold ${size}px Impact, Arial Black, sans-serif`;
    const lines = wrapText(ctx, text, maxWidth);
    if (lines.length <= 3) return size;
  }
  return minSize;
}

The Background Remover: Flood Fill + Edge Detection

This one was the most fun to build. Full-blown background removal like Photoshop uses ML models (and honestly, the best ones run server-side). But you can get surprisingly decent results with a tolerance-based flood fill.

The idea: user clicks the background color, and the algorithm floods outward from that point, removing any pixel within a color tolerance:

function floodFillRemove(imageData, startX, startY, tolerance = 30) {
  const { data, width, height } = imageData;
  const visited = new Uint8Array(width * height);
  const stack = [[startX, startY]];

  const idx = (startY * width + startX) * 4;
  const targetR = data[idx];
  const targetG = data[idx + 1];
  const targetB = data[idx + 2];

  while (stack.length > 0) {
    const [x, y] = stack.pop();
    if (x < 0 || x >= width || y < 0 || y >= height) continue;

    const pos = y * width + x;
    if (visited[pos]) continue;
    visited[pos] = 1;

    const i = pos * 4;
    const dr = data[i] - targetR;
    const dg = data[i + 1] - targetG;
    const db = data[i + 2] - targetB;
    const diff = Math.sqrt(dr * dr + dg * dg + db * db);

    if (diff <= tolerance) {
      data[i + 3] = 0; // Set alpha to 0 (transparent)
      stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
    }
  }
}

This works well for solid-color backgrounds. A white wall, a green screen, a solid blue sky — the flood fill eats through those cleanly.

For gradients and complex backgrounds, I added an edge-aware mode that combines the flood fill with a Sobel edge detector. The edge detection finds boundaries in the image, and the flood fill stops at those boundaries:

function sobelEdges(imageData) {
  const { data, width, height } = imageData;
  const edges = new Float32Array(width * height);

  const gx = [-1, 0, 1, -2, 0, 2, -1, 0, 1];
  const gy = [-1, -2, -1, 0, 0, 0, 1, 2, 1];

  for (let y = 1; y < height - 1; y++) {
    for (let x = 1; x < width - 1; x++) {
      let sumX = 0, sumY = 0;
      let k = 0;

      for (let ky = -1; ky <= 1; ky++) {
        for (let kx = -1; kx <= 1; kx++) {
          const i = ((y + ky) * width + (x + kx)) * 4;
          const gray = (data[i] + data[i+1] + data[i+2]) / 3;
          sumX += gray * gx[k];
          sumY += gray * gy[k];
          k++;
        }
      }

      edges[y * width + x] = Math.sqrt(sumX * sumX + sumY * sumY);
    }
  }

  return edges;
}

The Sobel operator runs two 3×3 kernels across the image — one detecting horizontal edges, one detecting vertical. The magnitude at each pixel tells you how “edgy” that spot is. I use a threshold (usually around 40-60) to decide where the flood fill should stop.

It’s not going to match Remove.bg on a photo of someone standing in a forest. But for product photos, logos, headshots with clean backgrounds — it works remarkably well for zero server cost.

Composite Operations: The Secret Weapon

Canvas has a feature called globalCompositeOperation that controls how new drawing operations blend with existing content. Most people only use the default (source-over), but there are 26 blend modes.

I use these heavily in the photo editor for layer effects:

// Color overlay
ctx.globalCompositeOperation = 'multiply';
ctx.fillStyle = '#ff6600';
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Screen blend (lightens)
ctx.globalCompositeOperation = 'screen';
ctx.drawImage(overlayCanvas, 0, 0);

// Draw only where existing content is
ctx.globalCompositeOperation = 'source-atop';
ctx.drawImage(textureCanvas, 0, 0);

source-atop is particularly useful — it draws new content only where there’s already opaque pixels. Great for applying textures or patterns to cut-out shapes without bleeding outside the edges.

Performance Notes

Pixel manipulation with getImageData is slow on large images. A 4000×3000 photo has 12 million pixels, and iterating through all of them in JavaScript takes real time.

Two things that helped:

  1. Downsample for preview, full-res for export. While the user is adjusting sliders, I apply filters to a 800px-wide preview canvas. Only when they hit “Download” do I process the full resolution.

  2. Typed arrays over regular arrays. ImageData.data is already a Uint8ClampedArray, so operations on it are fast. But if you’re doing intermediate calculations, use Float32Array instead of regular arrays — the typed array operations get JIT-optimized way better.

Try Them

All three tools are live and free:

No uploads, no accounts, no watermarks. Your images stay in your browser the entire time. If you’re curious how any specific part works, the JavaScript is all right there in the page source.

I Built an AI That Detects Pet Stress From Photos — Here’s the Stack

I Built an AI That Detects Pet Stress From Photos — Here’s the Stack

Everyone’s building AI for humans. I thought: what about dogs?

My dog Biscuit has “resting panic face.” He looks catastrophically stressed at all times — even when he’s asleep. Vets kept telling me he was fine. I didn’t believe them. So I did what any reasonable developer does: I over-engineered a solution.

This is the story of how I built a pet stress detection API using computer vision, and what I learned shipping it.

The Problem (In Dog Terms)

Animals communicate stress through body language — ear position, tail carriage, muscle tension, eye shape. Humans miss ~70% of these signals according to veterinary behaviour research. We’re wired to anthropomorphise: a dog “smiling” is often a stress pant.

The question: can a model learn to read these signals reliably from a standard smartphone photo?

Short answer: Yes, surprisingly well.

The Stack

Python 3.12
FastAPI (inference endpoint)
Hugging Face Transformers (ViT base)
OpenCV (preprocessing)
PostgreSQL (results storage)
Vercel (frontend)

Step 1 — Dataset Collection

I scraped ~14,000 labelled animal behaviour images from academic sources (AniML-Behavior dataset + manual labels from certified animal behaviourists).

Label categories:

  • relaxed
  • mildly_aroused
  • stressed
  • fearful
import pandas as pd
from pathlib import Path

def build_manifest(image_dir: str, label_csv: str) -> pd.DataFrame:
    df = pd.read_csv(label_csv)
    df['path'] = df['filename'].apply(
        lambda f: str(Path(image_dir) / f)
    )
    # Drop low-confidence labels (inter-rater < 0.7)
    df = df[df['confidence'] >= 0.70]
    print(f"Dataset: {len(df)} samples across {df['label'].nunique()} classes")
    return df

Output:

Dataset: 11,847 samples across 4 classes

Step 2 — Fine-Tuning ViT

I started with google/vit-base-patch16-224 and fine-tuned on the labelled dataset. The patch-based attention mechanism turned out to be great for picking up localised cues — ear tips, eye whites, jaw tension.

from transformers import ViTForImageClassification, TrainingArguments, Trainer
import torch

model = ViTForImageClassification.from_pretrained(
    "google/vit-base-patch16-224",
    num_labels=4,
    id2label={0: "relaxed", 1: "mildly_aroused", 2: "stressed", 3: "fearful"},
    label2id={"relaxed": 0, "mildly_aroused": 1, "stressed": 2, "fearful": 3},
    ignore_mismatched_sizes=True,
)

training_args = TrainingArguments(
    output_dir="./pet-stress-vit",
    num_train_epochs=6,
    per_device_train_batch_size=32,
    learning_rate=2e-5,
    weight_decay=0.01,
    evaluation_strategy="epoch",
    save_strategy="best",
    load_best_model_at_end=True,
    metric_for_best_model="f1",
)

After 6 epochs: 82.4% accuracy on the hold-out set. Vet behaviourist benchmark for the same images: 78%.

The model beat humans. Biscuit vindicated.

Step 3 — The FastAPI Endpoint

from fastapi import FastAPI, UploadFile, File
from PIL import Image
from transformers import ViTImageProcessor, ViTForImageClassification
import torch, io

app = FastAPI()
processor = ViTImageProcessor.from_pretrained("./pet-stress-vit")
model = ViTForImageClassification.from_pretrained("./pet-stress-vit")
model.eval()

@app.post("/predict")
async def predict_stress(file: UploadFile = File(...)):
    contents = await file.read()
    image = Image.open(io.BytesIO(contents)).convert("RGB")
    inputs = processor(images=image, return_tensors="pt")

    with torch.no_grad():
        logits = model(**inputs).logits

    probs = torch.softmax(logits, dim=-1)[0]
    predicted_class = model.config.id2label[logits.argmax(-1).item()]
    confidence = probs.max().item()

    return {
        "label": predicted_class,
        "confidence": round(confidence, 3),
        "scores": {
            model.config.id2label[i]: round(probs[i].item(), 3)
            for i in range(len(probs))
        }
    }

Example response for a photo of Biscuit:

{
  "label": "stressed",
  "confidence": 0.871,
  "scores": {
    "relaxed": 0.041,
    "mildly_aroused": 0.088,
    "stressed": 0.871,
    "fearful": 0.000
  }
}

Told you. Permanently stressed.

Step 4 — The Hard Part (Latency)

The ViT inference was taking ~1.2s on CPU. Too slow for a “snap a photo” UX. Three things fixed it:

  1. ONNX export — cut inference to ~190ms
  2. Image resize on client — send 224×224 px, not 4K originals
  3. Async queue — non-urgent requests batch every 500ms
# Export to ONNX
torch.onnx.export(
    model,
    dummy_input,
    "pet-stress.onnx",
    opset_version=14,
    input_names=["pixel_values"],
    output_names=["logits"],
    dynamic_axes={"pixel_values": {0: "batch"}},
)

P95 latency after optimisation: ~210ms. Usable.

What I Learned

1. Domain-specific data beats model size. A fine-tuned ViT-Base stomps GPT-4V on this task — and it runs on a $6/mo VPS.

2. Calibration matters more than accuracy. An 82% accurate model that’s confident when wrong is worse than a 78% model that knows its limits.

3. Non-technical users trust visuals. Adding a heatmap overlay (Grad-CAM) showing which body parts drove the prediction increased user trust massively — even when they didn’t understand it.

from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.image import show_cam_on_image

cam = GradCAM(model=model, target_layers=[model.vit.encoder.layer[-1].layernorm_before])
grayscale_cam = cam(input_tensor=inputs['pixel_values'])
visualization = show_cam_on_image(rgb_img, grayscale_cam[0], use_rgb=True)

Where This Is Going

Computer vision for animal behaviour is genuinely underexplored. Most vet-tech is billing software and appointment schedulers. The signal detection problem is wide open.

If you’re working on anything in the pet health / animal behaviour space — or you’re curious about the model weights — feel free to reach out. The team at MyPetTherapist is building exactly this kind of AI-assisted behavioural matching, connecting stressed pets with the right therapist based on actual behavioural signals, not just owner guesswork.

Biscuit is still stressed. But now I have data to prove it.

Built something weird with computer vision? Drop it in the comments — I read every one.

The Privacy Problem with Online PDF Tools (and How I Fixed It)

Last year I needed to merge two PDFs before sending them to my landlord. I Googled “merge PDF online,” clicked the first result, and uploaded my lease agreement and bank statement to some random website.

Then I thought about what I’d just done.

I’d handed a stranger my full legal name, address, bank account number, and monthly income. The site’s privacy policy was 4,000 words of legalese that boiled down to “we can do whatever we want with your data.”

That bugged me enough to build my own PDF tools. They run entirely in the browser. Your files never touch a server.

How Bad Is It, Really?

I tested the top 10 PDF tools from Google search results. Here’s what I found:

  • 8 out of 10 upload your file to their server for processing
  • 3 store files for “up to 24 hours” (their words)
  • 2 had privacy policies that allowed sharing data with “partners”
  • 1 didn’t even have HTTPS on the upload endpoint (yikes)

Think about the kinds of documents people put through PDF tools:

  • Legal: contracts, NDAs, court filings
  • Medical: lab results, insurance claims, prescriptions
  • Financial: tax returns, bank statements, pay stubs
  • Business: internal memos, hiring docs, IP filings

Every one of those is a goldmine for anyone who intercepts or scrapes them. And you’re trusting that some free PDF tool with ads plastered everywhere is handling your data responsibly.

Client-Side PDF Processing Is Real

Here’s the thing — you don’t actually need a server for most PDF operations. The pdf-lib library lets you manipulate PDFs entirely in JavaScript, running in the browser.

Merging PDFs

Merging is probably the most common operation. With pdf-lib, it’s straightforward:

import { PDFDocument } from 'pdf-lib';

async function mergePDFs(pdfFiles) {
  const merged = await PDFDocument.create();

  for (const file of pdfFiles) {
    const bytes = await file.arrayBuffer();
    const doc = await PDFDocument.load(bytes);
    const pages = await merged.copyPages(doc, doc.getPageIndices());
    pages.forEach(page => merged.addPage(page));
  }

  const mergedBytes = await merged.save();
  return new Blob([mergedBytes], { type: 'application/pdf' });
}

That’s it. The file goes from <input type="file"> to arrayBuffer to pdf-lib, and back out as a downloadable blob. At no point does it leave the browser.

Splitting PDFs

Splitting works the same way — you’re just copying specific pages instead of all of them:

async function splitPDF(pdfFile, pageRanges) {
  const bytes = await pdfFile.arrayBuffer();
  const source = await PDFDocument.load(bytes);
  const results = [];

  for (const range of pageRanges) {
    const newDoc = await PDFDocument.create();
    const indices = [];
    for (let i = range.start - 1; i < range.end; i++) {
      indices.push(i);
    }
    const pages = await newDoc.copyPages(source, indices);
    pages.forEach(page => newDoc.addPage(page));
    results.push(await newDoc.save());
  }

  return results;
}

Want pages 1-3 as one file and pages 4-10 as another? Pass [{start: 1, end: 3}, {start: 4, end: 10}] and you get two separate PDFs back.

Compressing PDFs

Compression is trickier because pdf-lib doesn’t have a built-in “compress” function. But most PDF bloat comes from embedded images. The approach I took:

  1. Parse the PDF and find all embedded images
  2. Decode each image
  3. Re-encode it at lower quality using Canvas + toBlob
  4. Replace the original image data in the PDF
async function compressImage(imageBytes, quality = 0.7) {
  const blob = new Blob([imageBytes]);
  const bitmap = await createImageBitmap(blob);

  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0);

  return new Promise(resolve => {
    canvas.toBlob(resolve, 'image/jpeg', quality);
  });
}

This typically shrinks image-heavy PDFs by 40-60%. Text-only PDFs don’t change much, but those are usually small anyway.

PDF to Image

For rendering PDF pages as images, I use pdf.js (Mozilla’s PDF renderer). It draws each page onto a Canvas element:

async function pdfPageToImage(pdfDoc, pageNum, scale = 2) {
  const page = await pdfDoc.getPage(pageNum);
  const viewport = page.getViewport({ scale });

  const canvas = document.createElement('canvas');
  canvas.width = viewport.width;
  canvas.height = viewport.height;

  await page.render({
    canvasContext: canvas.getContext('2d'),
    viewport
  }).promise;

  return canvas.toDataURL('image/png');
}

The scale factor matters a lot. Scale 1 gives you screen-resolution output. Scale 2 gives you print-quality. Scale 3 if you really need sharp text. Each jump doubles the pixel count, so don’t go crazy with it.

The Verification Problem

Here’s the hard part: how does a user actually verify their files aren’t being uploaded? They shouldn’t have to take my word for it.

A few things I did to make this transparent:

No network requests after page load. Open DevTools, go to the Network tab, and watch. After the initial page load, there’s zero network activity when you process a file. You can even disconnect your WiFi and it still works.

Open source approach. All the processing logic is in the client-side JavaScript. View source and you can trace exactly what happens to your file. There’s no minified blob hiding a sneaky fetch() call.

No analytics on file content. I track page views (everyone does), but I have zero visibility into what files users process. I literally couldn’t access your data if I wanted to.

Why Don’t More Tools Work This Way?

Money, mostly. Server-side processing means:

  • You can gate features behind paywalls more easily
  • You can collect data for advertising
  • You can impose artificial limits to push upgrades
  • You control the processing pipeline

Client-side tools give up all of that. The tradeoff is that users get better privacy and faster processing (no upload/download wait), but the developer has fewer monetization levers.

I’m fine with that tradeoff. The tools are free, they work offline, and your files stay yours.

Try Them Out

All the PDF tools are live:

Drop your files in, get your result out. Nothing uploaded, nothing stored, nothing tracked. If you want to verify, open DevTools and watch the Network tab — you’ll see exactly zero outbound requests during processing.

How I Built a Client-Side Video to GIF Converter with Zero Dependencies

I spent a weekend building a video-to-GIF converter that runs entirely in the browser. No server, no uploads, no ffmpeg binary — just raw JavaScript turning video frames into an animated GIF. Here’s how it actually works under the hood.

Why Bother?

Most online GIF makers upload your video to some server, process it, and send back the result. That’s fine until you realize:

  • Your video sits on someone else’s machine
  • It’s slow because you’re waiting on network round-trips
  • File size limits are usually tiny
  • It breaks when the service goes down

I wanted something that processes everything locally. The video never leaves your browser tab.

Extracting Frames from Video

The first step is pulling individual frames out of a video file. The <video> element + <canvas> combo makes this surprisingly doable:

async function extractFrames(videoFile, fps = 10) {
  const video = document.createElement('video');
  video.muted = true;
  video.src = URL.createObjectURL(videoFile);

  await new Promise(resolve => {
    video.onloadedmetadata = resolve;
  });

  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  const ctx = canvas.getContext('2d');

  const frames = [];
  const interval = 1 / fps;

  for (let time = 0; time < video.duration; time += interval) {
    video.currentTime = time;
    await new Promise(resolve => {
      video.onseeked = resolve;
    });
    ctx.drawImage(video, 0, 0);
    frames.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
  }

  URL.revokeObjectURL(video.src);
  return frames;
}

The trick here is video.onseeked. When you set currentTime, the browser doesn’t jump to that frame instantly — it’s async. You have to wait for the seeked event before drawing to the canvas, or you’ll grab stale frames.

Performance Gotcha

Extracting frames from a 30-second video at 10fps gives you 300 frames. Each one is a full ImageData object sitting in memory. For a 1080p video, that’s roughly 1920 * 1080 * 4 bytes = ~8MB per frame. 300 frames = 2.4GB of raw pixel data.

So you have to be smart about it. I scale the video down before extracting:

const maxWidth = 480;
const scale = Math.min(1, maxWidth / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;

480px wide is plenty for most GIFs, and it cuts memory usage by 75% compared to 1080p.

The GIF Encoder: LZW Compression from Scratch

This is where it gets interesting. The GIF format uses LZW (Lempel-Ziv-Welch) compression, and I implemented it without pulling in any library.

GIF files follow the GIF89a spec. The binary structure looks like this:

  1. Header: GIF89a (6 bytes)
  2. Logical Screen Descriptor: width, height, color table info (7 bytes)
  3. Global Color Table: up to 256 RGB colors
  4. Netscape Extension: for looping (19 bytes)
  5. For each frame: Graphic Control Extension + Image Data
  6. Trailer: 0x3B (1 byte)

The Netscape Application Extension is what makes the GIF loop. Without it, the animation plays once and stops:

function writeNetscapeExtension(buffer, offset) {
  buffer[offset++] = 0x21; // Extension introducer
  buffer[offset++] = 0xFF; // Application extension
  buffer[offset++] = 0x0B; // Block size
  // "NETSCAPE2.0"
  const id = 'NETSCAPE2.0';
  for (let i = 0; i < 11; i++) {
    buffer[offset++] = id.charCodeAt(i);
  }
  buffer[offset++] = 0x03; // Sub-block size
  buffer[offset++] = 0x01; // Loop indicator
  buffer[offset++] = 0x00; // Loop count (low byte) — 0 = infinite
  buffer[offset++] = 0x00; // Loop count (high byte)
  buffer[offset++] = 0x00; // Block terminator
  return offset;
}

The LZW compression itself works by building a dictionary of pixel patterns as it scans through the image:

function lzwEncode(pixels, colorDepth) {
  const minCodeSize = Math.max(2, colorDepth);
  const clearCode = 1 << minCodeSize;
  const eoiCode = clearCode + 1;

  let codeSize = minCodeSize + 1;
  let nextCode = eoiCode + 1;
  const table = new Map();

  // Initialize dictionary with single-character codes
  for (let i = 0; i < clearCode; i++) {
    table.set(String(i), i);
  }

  const output = [];
  output.push(clearCode);

  let current = String(pixels[0]);

  for (let i = 1; i < pixels.length; i++) {
    const next = String(pixels[i]);
    const combined = current + ',' + next;

    if (table.has(combined)) {
      current = combined;
    } else {
      output.push(table.get(current));
      if (nextCode < 4096) {
        table.set(combined, nextCode++);
        if (nextCode > (1 << codeSize) && codeSize < 12) {
          codeSize++;
        }
      } else {
        output.push(clearCode);
        table.clear();
        for (let j = 0; j < clearCode; j++) {
          table.set(String(j), j);
        }
        codeSize = minCodeSize + 1;
        nextCode = eoiCode + 1;
      }
      current = next;
    }
  }

  output.push(table.get(current));
  output.push(eoiCode);

  return { codes: output, minCodeSize };
}

The 4096 limit is baked into the GIF spec — codes are 12 bits max. When you hit the limit, you emit a clear code and reset the dictionary.

Color Quantization

GIF only supports 256 colors per frame. Video frames have millions. You need a quantization step to map the full RGB space down to a 256-color palette.

I went with median-cut quantization. It works by repeatedly splitting the RGB color space along whichever axis has the widest range:

function medianCut(pixels, maxColors) {
  let buckets = [{ colors: pixels }];

  while (buckets.length < maxColors) {
    // Find the bucket with the widest color range
    let widest = 0, widestRange = 0, widestChannel = 0;

    for (let i = 0; i < buckets.length; i++) {
      for (let ch = 0; ch < 3; ch++) {
        const vals = buckets[i].colors.map(c => c[ch]);
        const range = Math.max(...vals) - Math.min(...vals);
        if (range > widestRange) {
          widestRange = range;
          widest = i;
          widestChannel = ch;
        }
      }
    }

    const bucket = buckets.splice(widest, 1)[0];
    bucket.colors.sort((a, b) => a[widestChannel] - b[widestChannel]);
    const mid = Math.floor(bucket.colors.length / 2);
    buckets.push({ colors: bucket.colors.slice(0, mid) });
    buckets.push({ colors: bucket.colors.slice(mid) });
  }

  return buckets.map(b => {
    const avg = [0, 0, 0];
    b.colors.forEach(c => { avg[0] += c[0]; avg[1] += c[1]; avg[2] += c[2]; });
    return avg.map(v => Math.round(v / b.colors.length));
  });
}

This gives way better results than just picking the most frequent colors. The palette adapts to the actual color distribution in each frame.

Putting It All Together

The final pipeline: extract frames → quantize colors → encode each frame with LZW → pack into GIF binary.

I added a progress bar since encoding can take 5-10 seconds for longer videos. Users seeing a frozen screen is a guaranteed bounce.

The whole thing runs in the main thread, which isn’t ideal. Moving the encoder into a Web Worker would keep the UI responsive during heavy encodes — that’s on my list.

Try It

The converter is live at devtools-site-delta.vercel.app/video-to-gif. Drop a video file, pick your FPS and quality, and it spits out a GIF. Nothing gets uploaded anywhere.

If you’re building something similar, the biggest lesson I learned: get the color quantization right first. Bad palettes make everything else look broken even if your encoder is perfect.

I Built an LSTM Trading Bot That Beats Buy-and-Hold on 67% of Tickers — Here’s What I Learned

Every quant’s journey starts the same way: “What if I could predict the market?” Most quit after their first linear regression spits out noise. I didn’t quit — I built GodFin, an LSTM-based trading bot that generates alpha over buy-and-hold on 67% of tested tickers. Here’s the honest story of what worked, what didn’t, and what I learned.

What Is GodFin?

GodFin is a Python-based algorithmic trading system I built from scratch. It uses deep learning to generate daily buy/sell/hold signals for individual equities. The core idea: markets have short-term sequential patterns that a well-tuned recurrent neural network can exploit — not perfectly, not always, but often enough to beat passive holding.

Important disclaimer upfront: GodFin runs on paper trading only. Every result I share here is from simulated execution, not live capital. I’m not here to sell you a dream — I’m here to show you what’s technically possible and where the walls are.

The Architecture: LSTM + Attention

The model stack is straightforward but deliberate:

Input Features (OHLCV + technicals, ~40 features)
    → Normalization Layer
    → LSTM (2 layers, 128 hidden units)
    → Multi-Head Self-Attention
    → Dense → Dropout → Dense
    → 3-class output (Buy / Hold / Sell)

Why LSTM over Transformer-only? For daily-frequency equity data, you typically have 500–1000 training samples per ticker after walk-forward splits. Transformers are data-hungry. LSTMs handle small sequential datasets more gracefully, and the attention layer on top lets the model weight which timesteps matter most for the current prediction.

Features include standard technicals (RSI, MACD, Bollinger width, ATR) plus some custom ones: volume regime z-scores, rolling volatility ratios, and a momentum divergence signal. Nothing exotic — the alpha comes from the model learning interactions between these features over time, not from any single magic indicator.

Walk-Forward Validation: The Only Backtest That Matters

If you’re backtesting with a random train/test split on time series data, stop. You’re leaking future information.

GodFin uses strict walk-forward validation: train on months 1–6, validate on month 7, retrain on months 1–7, validate on month 8, and so on. The model never sees future data during training. Period.

Here are the alpha results (model return minus buy-and-hold return) across four tickers over the same validation window:

Ticker Model Return Buy-and-Hold Alpha
CZR +32.14% +6.65% +25.49%
COIN +24.87% +5.45% +19.42%
HOOD +18.22% +5.13% +13.09%
PLTR -8.41% +11.37% -19.78%

Three out of four tickers showed meaningful positive alpha. That’s the 67% headline number — and yes, I’m counting honestly. PLTR was a loss.

What Went Wrong With PLTR

PLTR’s price action during the test window was dominated by a sentiment-driven rally tied to government contract news. The model’s technical features had no way to capture that. It kept generating sell signals into a momentum rip, got stopped out repeatedly, and underperformed badly.

Lesson: LSTM models trained on price/volume technicals are fundamentally blind to narrative-driven moves. If I were to improve this, I’d need to integrate NLP sentiment signals from news or social media — a meaningful infrastructure addition, not a quick fix.

This is exactly the kind of result you need to be honest about. Cherry-picking the three winners and hiding PLTR would make great marketing. It would also be dishonest. Every model has failure modes. Know yours.

The Subprocess Isolation RAM Fix

Here’s a war story that has nothing to do with ML theory and everything to do with production engineering.

GodFin trains separate models per ticker. Initially, I ran all training sequentially in a single Python process. After 5-6 tickers, RAM usage ballooned past 12GB. PyTorch doesn’t release GPU/CPU memory cleanly between training runs in the same process — torch.cuda.empty_cache() helps but doesn’t fully solve it, and on CPU-only workloads (my setup), there’s no equivalent.

The fix: subprocess isolation. Each ticker’s training runs in its own subprocess.Popen child process. When the child exits, the OS reclaims all memory. Clean. Simple. Brutal.

import subprocess, json, sys

def train_ticker(ticker: str, config_path: str) -> dict:
    result = subprocess.run(
        [sys.executable, "train_single.py", "--ticker", ticker, "--config", config_path],
        capture_output=True, text=True, timeout=600
    )
    if result.returncode != 0:
        raise RuntimeError(f"{ticker} training failed: {result.stderr[-500:]}")
    return json.loads(result.stdout)

RAM usage went from “crashes after 6 tickers” to “stable at ~3GB for 20+ tickers.” This isn’t a novel technique — it’s standard practice in ML pipelines — but it’s the kind of thing that takes you hours to debug when you don’t know to look for it.

Key Takeaways

1. Walk-forward or nothing. Any other validation scheme on time series is self-deception. If your backtest looks too good, it probably is.

2. Per-ticker models beat universal models (at this scale). Each equity has its own volatility regime and feature relationships. A single model across all tickers diluted signal quality significantly in my tests.

3. Attention over raw LSTM matters. The attention layer improved validation accuracy by ~4% on average. It lets the model focus on specific lookback periods rather than treating all 60 timesteps equally.

4. Infrastructure is half the battle. The subprocess RAM fix, proper logging, and automated walk-forward pipelines took as much engineering time as the model itself. If your ML code works in a notebook but not in production, you don’t have a trading system — you have a demo.

5. Be honest about failures. PLTR lost. The model isn’t omniscient. Publishing only your wins is how you end up as a cautionary tale on r/algotrading.

What’s Next

I’m building GodFin into a platform where users can track these signals in real time — still paper trading, still transparent about what the model can and can’t do. If you’re interested in following along as I scale this from 4 tickers to 50+ and add sentiment integration, join the waitlist at godfinapp.com.

No promises of returns. No “guaranteed alpha.” Just an honest, open-source-minded approach to algorithmic trading with deep learning.

All results shown are from paper trading simulations. Past simulated performance does not guarantee future results. This is not financial advice.

Many Days Later

I owe you an apology.
I said I was going to document my journey learning Rust daily…

Yeah… that didn’t happen 😅

Life happened. Work happened. Overthinking happened.

But I didn’t stop learning.

And honestly, that’s what matters.

So here we are — many days later.

Quick Recap

Last time, we:

  • Installed Rust
  • Built a simple guessing game
  • Learned basic syntax (let, mut, match, use)

At that point, Rust felt…

“Not that scary.”

But then I kept going.

And I finally met the thing everyone warns you about:

Ownership

The Project

Before we dive in, let me make this real.

I’m not just learning Rust for fun.

I’m building something.

A ride sharing app.

Think:

  • requesting rides
  • matching drivers
  • tracking trips
  • handling payments (eventually)

Why this?

Because it’s complex enough to force me to actually understand Rust, not just play with syntax.

So… What is Ownership?

Let me explain this in the simplest way possible.

Ownership is basically:

Who owns a piece of data in your program

And Rust is VERY strict about this.

Imagine This

You have money.

Only one person can own it at a time.

You can:

  • give it away
  • borrow it
  • or keep it

But you can’t have two real owners at the same time.

That’s exactly how Rust treats data.

Example Time

let s1 = String::from("hello");
let s2 = s1;

Looks harmless, right?

Wrong.

After this:

  • s1 is no longer valid
  • s2 now owns the data

If you try to use s1, Rust will stop you immediately.

Why Does Rust Do This?

Because Rust wants to prevent:

  • memory leaks
  • crashes
  • weird bugs

Languages like JavaScript or Python hide this from you.

Rust says:

“No. You will deal with memory properly.”

At first, it feels annoying.

Then you realize:

This is actually protecting you.

The First Confusing Moment

When I saw this, I was like:

Wait… why can’t I use s1 anymore?

It felt unnecessary.

But then it clicked.

Rust is making sure:

There is only one clear owner of data at any time.

Borrowing (This Saved Me)

Instead of giving away ownership, you can borrow.

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("Length: {len}");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Notice this:

&s1

That & means:

“I’m borrowing this, not taking it.”

So:

  • s1 is still valid
  • the function just uses it temporarily

Real Life Meaning

Think of it like this:

  • Ownership → giving someone your car
  • Borrowing → letting someone drive your car

Big difference.

Why This Matters (Especially for My Project)

Remember the ride sharing app?

We’ll have:

  • users
  • drivers
  • trips
  • locations

Data will be moving everywhere.

Without ownership rules, things could get messy:

  • multiple parts of the app modifying the same data
  • crashes
  • inconsistent states

Rust prevents this at compile time.

Meaning:

Bugs are caught BEFORE the app runs.

That’s huge.

My Honest Experience

Ownership is:

  • confusing at first
  • frustrating in the beginning
  • but makes sense over time

I won’t lie…

I had to re-read parts of the Rust book multiple times.

But once it clicks, you start seeing:

“Oh… this is actually clean.”

What I’ve Learned So Far

After spending more time with Rust:

  • It forces discipline
  • It doesn’t allow shortcuts
  • It makes you think before coding

And I’m starting to understand why people say:

Rust changes how you think about programming

What’s Next

Now that ownership is (kind of) making sense…

Next up:

  • borrowing deeper
  • references
  • lifetimes (yes… that scary word)
  • and starting the ride sharing backend

Final Thoughts

I didn’t keep up with daily updates.

That’s on me.

But I didn’t quit.

And honestly, that’s the real journey.

We’re still going from:

0 → 1

Just not in a straight line.

7 Mac Apps Every Swift Developer Should Have in 2026

If you write Swift for a living — whether that’s iOS apps, macOS tools, or server-side Swift — your IDE is only part of the equation. The apps running alongside Xcode can make or break your workflow.

Here are 7 Mac apps I rely on daily as a Swift developer in 2026.

1. Warp — A Terminal That Actually Keeps Up

Warp feels like someone rebuilt the terminal for modern developers. It has built-in AI command suggestions, block-based output you can copy and share, and real autocompletion. Running swift build, xcodebuild, or managing SPM dependencies is noticeably smoother. The collaborative features are great if you pair program, too.

Free with premium tier available — warp.dev

2. Proxyman — Debug Network Calls Without the Pain

Proxyman is a native macOS HTTP debugging proxy, built in Swift itself. If your app talks to APIs (and what app doesn’t?), Proxyman lets you intercept, inspect, and mock network requests from your simulator or device. It’s faster and more intuitive than Charles Proxy, and the SwiftUI interface feels right at home on macOS.

Free tier + paid plans — proxyman.io

3. Raycast — The Launcher That Replaced Spotlight, Alfred, and 3 Other Apps

Raycast is a productivity launcher on steroids. You can trigger Xcode schemes, search documentation, manage clipboard history, and even write custom Swift script extensions. The snippet expansion alone saves me minutes every day. Once you start using it, you won’t go back to Spotlight.

Free for personal use — raycast.com

4. TokenBar — Know Exactly What Your LLM API Calls Cost

TokenBar sits in your menu bar and tracks token usage across OpenAI, Anthropic, and other LLM providers in real time. If you’re building anything with Swift that hits AI APIs — chatbots, code assistants, intelligent features — this shows you exactly what each request costs. No more surprise bills at the end of the month. At $5 lifetime, it pays for itself after catching one runaway API call.

$5 lifetime — tokenbar.site

5. Obsidian — Document Your Architecture Decisions

Obsidian is a local-first markdown editor with bidirectional linking. I use it to keep architecture decision records, API documentation, and SwiftUI component notes all in one searchable vault. The graph view is genuinely useful for seeing how your modules connect. Everything stays on your machine as plain markdown files — no vendor lock-in.

Free for personal use — obsidian.md

6. Monk Mode — Block the Feed, Not the App

Monk Mode does something clever: instead of blocking entire websites, it blocks the feed inside them. You can still use YouTube for documentation videos, but the homepage recommendations disappear. Same for Twitter, Reddit, LinkedIn. When you’re deep in a SwiftUI layout bug, the last thing you need is a doom-scroll break that turns into 45 minutes. $15 lifetime and it works at the DNS level.

$15 lifetime — mac.monk-mode.lifestyle

7. CleanShot X — Screenshots That Actually Communicate

CleanShot X replaces macOS’s built-in screenshot tool with something far more powerful. Annotate bugs, record quick screen videos for PR descriptions, blur sensitive data, and pin screenshots as floating windows while you reference them. For filing bug reports or documenting UI states across different device sizes, it’s indispensable.

$29 one-time — cleanshot.com

Honorable Mentions

  • Homebrew — If you’re not using it to manage Swift toolchains and dev dependencies, start now.
  • MetricSync — Not a dev tool, but worth mentioning: an iPhone AI nutrition tracker that lets you snap a photo instead of manually logging meals. $5/mo. I use it because coding marathons and bad eating habits go hand in hand.
  • Rectangle — Free, open-source window management. Essential for tiling Xcode next to Simulator.

Wrapping Up

The best developer tools are the ones that disappear into your workflow. These seven do exactly that — they handle the friction so you can focus on writing Swift. If you’ve got a Mac app that changed your Swift development workflow, drop it in the comments. Always looking for new additions to the stack.

Happy shipping. 🚀

Hosted vs Self-Hosted Predictive Dialer in 2026: The Real Cost Breakdown at Every Scale

The predictive dialer market is projected to hit $6.1 billion by 2034, growing at 7% annually. Every year, more vendors enter the hosted space with slick demos and “starting at” prices that look reasonable until invoice month three.

And every year, the same operations managers run the same spreadsheet and arrive at the same conclusion: hosted dialers are overpriced for what they deliver, but self-hosted dialers are underpriced for what they demand.

This article resolves that tension with real numbers. We priced out seven hosted platforms and self-hosted VICIdial at five different scale points (25, 50, 100, 200, and 500 seats), including every cost component that affects total cost of ownership. No “starting at” marketing numbers. No hidden line items on either side.

If you’re evaluating dialer platforms and need to present a cost comparison to your CFO, bookmark this page.

The Two Models, Stripped Down

Hosted (cloud-based) predictive dialers charge a per-seat monthly subscription. The vendor handles infrastructure, updates, and support. Your costs are predictable but scale linearly with seat count. You trade control for simplicity.

Examples: Five9, Convoso, Genesys Cloud CX, RingCentral RingCX, NICE CXone, Talkdesk, Dialpad AI Contact Center.

Self-hosted predictive dialers run on your infrastructure (physical servers, colocation, or dedicated cloud servers). The software is either open-source (free) or licensed. Your costs are infrastructure and labor, which scale sub-linearly with seat count. You trade simplicity for control.

Primary example: VICIdial (open-source, GPL licensed, running on 14,000+ installations globally).

The decision between them isn’t about which is “better.” It’s about which cost structure matches your operational reality at your specific scale.

Hosted Platform Pricing in 2026: Seven Platforms Compared

We researched published pricing, third-party analysis, and verified user reports for seven hosted platforms. The actual cost for a 100-seat outbound-focused operation:

Five9

Component Monthly (100 seats)
Core seats (100 x $159) $15,900
Telecom (high-volume outbound) $15,000
AI overage (beyond 3K min/seat) $2,000
Storage overage $500
Total $33,400
Per seat $334

Minimum 50 seats. 36-month contract with auto-renewal. Professional services: $25K-50K upfront.

Convoso

Component Monthly (100 seats)
Seats (100 x ~$150 est.) $15,000
Carrier fees $10,000
DID management (Ignite) $800
SMS add-on $1,500
Total $27,300
Per seat $273

Pricing not published — estimates from Capterra and user reports. Annual contract preferred. No free trial.

Genesys Cloud CX (CX 3)

Component Monthly (100 seats)
CX 3 seats (100 x $155) $15,500
Telecom (BYOC high-volume) $12,000
AI Experience tokens $2,500
Premium support $1,500
Total $31,500
Per seat $315

Professional services: $50K-150K. Add WFM and QM for CX 4 at $240/seat (+$85/seat).

RingCentral RingCX

Component Monthly (100 seats)
RingCX seats (100 x $65) $6,500
RingEX Advanced (100 x $25) $2,500
High-volume telecom overage $7,500
Total $16,500
Per seat $165

Requires RingEX base licenses in addition to RingCX. Best value in the hosted market.

NICE CXone

Component Monthly (100 seats)
Seats (100 x ~$135 est.) $13,500
Telecom $10,000
WFM add-on $2,000
Total $25,500
Per seat $255

Pricing not published — industry estimates. Strong WFM and quality management.

Talkdesk

Component Monthly (100 seats)
CX Cloud Elevate (100 x ~$115) $11,500
Telecom $8,000
AI add-ons (Agent Assist, QM) $3,000
Total $22,500
Per seat $225

3-year contract standard. Professional services required for custom integrations.

Dialpad AI Contact Center

Component Monthly (100 seats)
Seats (100 x ~$95 est.) $9,500
Telecom $6,000
AI add-ons $1,500
Total $17,000
Per seat $170

Requires Dialpad Business Communications base. AI-native but limited outbound dialer depth.

Self-Hosted VICIdial: The Real Numbers

VICIdial is free to download and install. The costs are infrastructure, telecom, and labor. Here’s the breakdown at multiple scales.

Infrastructure Architecture by Scale

Scale DB Server Web/Dialer Servers Recording Storage
25 seats 1 (combined) 1 (combined) Local RAID
50 seats 1 dedicated 1 dedicated Local RAID + NAS
100 seats 1 (8-core, 32GB) 2 NAS or SAN
200 seats 1 (16-core, 64GB) 3 SAN + archival
500 seats 2 (primary + replica) 5 SAN + cloud archival

Detailed Cost Breakdown: 100-Seat Operation

Component Monthly Cost Annual Cost Notes
Database server $300 $3,600 Hetzner AX102 or similar: 16-core, 64GB, NVMe
Dialer/web server #1 $200 $2,400 8-core, 32GB, SSD
Dialer/web server #2 $200 $2,400 Redundancy + load distribution
Recording storage (NAS) $100 $1,200 4TB+ for monthly recordings
SIP trunking (Telnyx) $5,000 $60,000 ~$0.007/min, high volume
DID numbers (200) $300 $3,600 Rotation for caller ID health
System administration $2,500 $30,000 Managed hosting provider
Monitoring (Grafana stack) $100 $1,200 Server + call quality monitoring
Security (SSL, firewall, updates) $100 $1,200 Let’s Encrypt + iptables + patches
Backup $100 $1,200 Daily database + config backup
Total $8,900 $106,800
Per seat $89 $1,068

Cost Comparison Across All Scales

Here’s the table that matters. Annual total cost including everything — licensing, telecom, infrastructure, labor, and support:

Seats Five9 Convoso Genesys CX3 RingCX NICE Talkdesk Dialpad VICIdial
25 $116K $86K $108K $55K $83K $75K $57K $55K
50 $200K $164K $189K $99K $153K $135K $102K $72K
100 $401K $328K $378K $198K $306K $270K $204K $107K
200 $730K $590K $684K $372K $552K $486K $372K $170K
500 $1.7M $1.4M $1.6M $880K $1.3M $1.1M $880K $370K

All figures annualized, including telecom, add-ons, and infrastructure costs. VICIdial includes managed hosting.

The pattern is clear: hosted platforms scale linearly. VICIdial scales sub-linearly. At 25 seats, VICIdial’s infrastructure floor means it competes with (but doesn’t always beat) the cheapest hosted options. At 500 seats, VICIdial costs 21-27% of what the expensive hosted platforms charge and 42% of the cheapest.

The Crossover Point

VICIdial becomes the cheapest option at approximately 35-40 seats for most hosting configurations. Below that, RingCentral RingCX and Dialpad often win on pure cost because their seat pricing doesn’t carry the infrastructure floor.

The exception: if you’re already running the server infrastructure for other purposes (existing VoIP deployment, other Linux workloads), VICIdial’s marginal cost of adding a dialer is near-zero, making it the cheapest option even at 10 seats.

What Hosted Gives You That Self-Hosted Doesn’t

Honesty requires acknowledging the real advantages of hosted platforms. What you’re buying with that per-seat premium:

1. Zero Infrastructure Burden

Nobody on your team provisions servers, patches operating systems, tunes databases, or debugs Asterisk at 2 AM. The vendor handles all of it. For operations without technical staff, this isn’t a luxury — it’s a necessity.

The cost of infrastructure management in a self-hosted environment is real:

  • Junior Linux admin: $60-80K/year
  • Senior VICIdial/Asterisk admin: $90-130K/year
  • Managed hosting (ViciStack, ViciHost): $1,500-4,000/month

If you can’t hire or contract that expertise, self-hosting is not viable regardless of the cost savings.

2. Compliance Certifications

Five9, Genesys, and NICE hold SOC 2, PCI DSS, HIPAA, and other compliance certifications. For healthcare, financial services, and government operations, those certifications have procurement value that’s hard to replicate independently.

Self-hosted VICIdial can meet the same compliance standards, but you’re responsible for the audit, documentation, and ongoing maintenance. HIPAA compliance for a self-hosted dialer typically costs $15,000-30,000 in initial assessment and $5,000-10,000/year in ongoing compliance work.

3. AI Features

Every major hosted platform now includes AI capabilities: call summaries, agent coaching, sentiment analysis, quality scoring. These features are genuinely useful and getting better rapidly.

Self-hosted VICIdial has no native AI. You can integrate third-party tools (and ViciStack builds custom AI layers for VICIdial), but the out-of-box experience is zero AI.

4. Omnichannel

If your operation handles voice, email, chat, SMS, and social media through a single agent desktop, hosted platforms handle this natively. Building equivalent omnichannel capability on VICIdial requires significant integration work.

5. Vendor Support

When something breaks, you open a ticket and the vendor fixes it. You don’t debug Asterisk core dumps. You don’t trace SIP message flows. You don’t optimize MySQL slow queries.

The quality of that support varies dramatically (Five9 and Genesys get mixed reviews on support responsiveness), but having it available is better than being entirely on your own.

What Self-Hosted Gives You That Hosted Doesn’t

1. Dialer Control

This is the big one for outbound operations.

Self-hosted VICIdial exposes 2,000+ configuration settings across campaigns, lists, IVRs, agent behavior, and system parameters. You control the predictive algorithm, AMD thresholds, dial ratios, dropped call handling, and lead prioritization at a level no hosted platform matches.

; Example: Adaptive predictive dialer tuned for solar lead gen
Dial Method: ADAPT_HARD_LIMIT
Auto Dial Level: 4.0
Adaptive Maximum Level: 10.0
Adaptive Dropped Percentage: 2.0%
Adaptive Intensity: 0.85
Available Only Ratio Calcs: Y
AMD_type: AMD
AMD_send_to_message: Y
amd_initial_silence: 2600
amd_greeting: 1500
amd_after_greeting_silence: 800
amd_total_analysis_time: 5000
Hopper Level: 500
List Order: DOWN COUNT

That configuration — specific to one campaign on one server — gives you control over 15+ parameters that directly affect contact rates, agent utilization, and compliance. Hosted platforms give you 3-5 knobs to turn. VICIdial gives you the full control panel.

2. Data Ownership

Self-hosted means your data — call recordings, lead disposition records, agent performance metrics, CDRs, everything — lives on your servers. You have direct database access. You can run any query, export any dataset, and migrate to any platform at any time.

Hosted platforms hold your data on their infrastructure. Exporting is possible but constrained by their API capabilities and rate limits. Migration requires effort, and in some cases, data loss. And if you’re in a contract dispute with your vendor, your data is on their servers.

For operations that consider call data a strategic asset (training AI models, compliance archives, performance analysis), data ownership is non-negotiable.

3. No Vendor Lock-in

No 36-month auto-renewing contracts. No 50-seat minimums. No price increases pushed through on renewal. No “we’re deprecating this feature you depend on.”

Self-hosted VICIdial runs on your terms. If you want to switch SIP providers, you change a configuration file. If you want to add a server, you provision it. If you want to modify the dialer algorithm, you edit the source code.

4. Cost Predictability at Scale

Hosted platform costs scale linearly and include variable components (AI tokens, telecom overage, storage fees) that make budgeting unpredictable. A 100-seat Five9 deployment might cost $33K one month and $38K the next based on call volume.

Self-hosted costs are almost entirely fixed. Servers cost what they cost. SIP trunking bills per minute at a rate you negotiate. There are no surprise AI token bills, no storage overage charges, no “your usage exceeded the plan allowance” notifications.

5. Performance Tuning

When your hosted dialer has audio quality issues, you open a ticket. When your self-hosted dialer has audio quality issues, you check the RTP streams, trace the SIP path, verify codec negotiation, and fix it.

When your hosted dialer’s predictive algorithm isn’t aggressive enough, you ask the vendor to adjust it. When your self-hosted dialer needs tuning, you change the settings in real-time and watch the impact on the next batch of dials.

For operations where minutes of downtime cost thousands of dollars in lost revenue, the ability to diagnose and fix issues without waiting in a support queue is a material advantage.

The Hidden Costs of Self-Hosting (We’re Being Honest)

Self-hosted is not “free.” The software is free. Running it costs real money and real effort. Here are the costs that VICIdial advocates (including us) sometimes understate.

1. Learning Curve

VICIdial’s admin interface is dense. The first time you log in, you’ll see dozens of settings per campaign, hundreds of system-level parameters, and documentation that’s thorough but scattered across a wiki and forum.

A new VICIdial admin takes 2-4 weeks to become productive. An experienced one takes 2-3 months to master the platform. Compare that to hosted platforms where basic campaign setup takes hours.

Real cost: 40-80 hours of learning time before your first campaign runs well. If you’re hiring this expertise, budget $5,000-15,000 in training or consulting.

2. Ongoing Maintenance

Linux servers need patching. Asterisk has vulnerabilities that need updates. MariaDB needs monitoring and occasional optimization. SSL certificates need renewal. Disk space needs watching. Log files need rotation.

A weekly maintenance routine looks like this:

# Check Asterisk process and restart if needed
systemctl status asterisk

# Monitor /var/spool/asterisk/monitor/ disk usage
df -h /var/spool/asterisk/monitor/

# Rotate old recordings (move to NAS or archive)
find /var/spool/asterisk/monitor/ -name "*.wav" -mtime +90

# Check MySQL slow queries on port 3306
mysql -e "SHOW GLOBAL STATUS LIKE 'Slow_queries';"

# Verify fail2ban is blocking SIP scanners
iptables -L fail2ban-asterisk -n | wc -l

# Check /etc/asterisk/sip.conf for unauthorized registrations
asterisk -rx "sip show peers" | grep -v "OK"

# Verify crontab is intact
crontab -l | grep -c vicidial

Real cost: 4-8 hours/week of system administration for a 100-seat deployment. If you’re paying a managed provider, that’s $1,500-4,000/month. If you have in-house staff, that’s time they’re not spending on other tasks.

3. Disaster Recovery

Hosted platforms handle backup and DR for you (usually). Self-hosted means you build your own backup strategy, test your own recovery procedures, and maintain your own redundancy.

Real cost: $200-500/month for backup infrastructure and storage. Plus the time to build, test, and maintain the DR plan.

4. Security Responsibility

With hosted platforms, the vendor handles server security, DDoS protection, and vulnerability management. Self-hosted means you handle it.

VICIdial servers exposed to the internet without proper firewall rules, fail2ban, and access controls are targets for toll fraud and SIP scanning attacks. We’ve seen unsecured VICIdial installations racking up $50,000 in fraudulent international calls in a single weekend.

Real cost: Proper security setup takes 8-16 hours initially and 2-4 hours/month for ongoing maintenance. Or you use a managed provider who handles it.

5. The SysAdmin Single-Point-of-Failure Problem

If one person on your team knows VICIdial and they leave, you have a problem. The platform is powerful but not intuitive, and replacing VICIdial expertise on short notice is difficult.

Mitigation: Use a managed hosting provider (so expertise isn’t tied to one employee), document your configuration, and maintain relationships with the VICIdial consulting community.

Decision Framework by Scale

Under 25 Seats

Recommendation: Hosted (RingCentral RingCX or Dialpad)

The infrastructure floor for self-hosted VICIdial makes per-seat costs higher than the cheapest hosted options at this scale. You’d be paying $150+/seat for VICIdial infrastructure that could handle 100 seats but is only serving 25.

Exception: if you’re starting small but plan to grow past 50 seats within 12 months, start with self-hosted to avoid the migration pain later.

25-50 Seats

Recommendation: Either, depending on your technical capacity

This is the crossover zone. Self-hosted VICIdial breaks even with hosted platforms around 35-40 seats. If you have or can hire technical staff, self-hosted starts making financial sense. If you don’t, hosted is still the pragmatic choice.

50-100 Seats

Recommendation: Self-hosted VICIdial (with managed hosting if no in-house expertise)

At this scale, self-hosted VICIdial costs 40-60% of what hosted platforms charge. The savings — $100,000-200,000 annually — justify either hiring a Linux admin or contracting managed hosting. The operational complexity is manageable, and you’re in VICIdial’s sweet spot.

100-200 Seats

Recommendation: Self-hosted VICIdial (strongly)

The cost delta is massive. $200,000-500,000 in annual savings depending on which hosted platform you’re comparing against. At this scale, you should have in-house technical staff plus a managed hosting relationship for backup.

200+ Seats

Recommendation: Self-hosted VICIdial (unless you need enterprise omnichannel)

At 500 seats, VICIdial saves $500K-1.3M annually vs hosted platforms. The only scenario where hosted makes sense at this scale is if you genuinely need enterprise omnichannel, global compliance certifications, and AI capabilities that would cost more to build in-house than the hosted premium.

The Migration Factor

Switching from hosted to self-hosted (or vice versa) is not free. Factor these costs into your decision:

Hosted to Self-Hosted Migration

Component Estimated Cost
VICIdial deployment + configuration $5,000-15,000
Data migration (leads, dispositions, recordings) $5,000-20,000
Agent retraining $2,000-5,000
Parallel running period (1-2 months) Double costs during overlap
SIP trunk setup and testing $1,000-3,000
Total $15,000-45,000

Self-Hosted to Hosted Migration

Component Estimated Cost
Hosted platform implementation $15,000-50,000
Data export from VICIdial $2,000-5,000
Data import to hosted platform $5,000-15,000
Agent retraining $3,000-8,000
Recording migration (if applicable) $1,000-10,000
Total $25,000-90,000

The migration cost means this isn’t a decision to make lightly. Switching platforms mid-operation disrupts production. Plan accordingly.

The 3-Year TCO Comparison

Decision-makers think in multi-year terms. Here’s the 3-year total cost of ownership including implementation, migration (if applicable), and ongoing operations:

100-Seat Operation: 3-Year TCO

Platform Year 1 Year 2 Year 3 3-Year Total
Five9 $441K $401K $401K $1,243K
Convoso $358K $328K $328K $1,014K
Genesys CX3 $528K $378K $378K $1,284K
RingCX $222K $198K $198K $618K
VICIdial (managed) $127K $107K $107K $341K
VICIdial (in-house admin) $187K $167K $167K $521K

Year 1 includes implementation/setup costs. VICIdial in-house admin includes $80K/year for a dedicated admin.

Over three years, VICIdial with managed hosting saves $277K-943K compared to hosted alternatives. Even with a dedicated in-house admin, the savings range from $97K to $763K.

Our Recommendation

If you’re reading this article, you’re probably running a 50+ seat outbound operation and evaluating whether the hosted platform premium is worth what you’re paying. For most outbound-focused operations above 50 seats, it’s not.

Self-hosted VICIdial with professional management delivers 80%+ of what hosted platforms offer for outbound dialing — the use case that actually generates your revenue — at 30-50% of the cost. The 20% you’re missing (AI features, native omnichannel, modern UI) can be addressed through targeted integration work that costs a fraction of the per-seat premium you’d pay for a hosted platform.

We handle the hard parts of self-hosted VICIdial: deployment, optimization, monitoring, and ongoing management. We take the platform and add the enterprise features — AI quality monitoring, STIR/SHAKEN compliance, real-time performance dashboards, and optimized dialer configurations — that bridge the gap between open-source and enterprise.

Our offer: we’ll audit your current dialer deployment (hosted or self-hosted) and build a custom TCO comparison showing exactly what you’d save. If the numbers make sense and you switch to optimized VICIdial, we guarantee a 50% conversion increase in two weeks. $5,000 flat ($1,000 down, $4,000 on delivery). Start the conversation at vicistack.com.

Quick Reference: Which Platform, Which Scale

Your Situation Best Choice Why
Under 25 seats, no tech staff RingCentral RingCX Lowest cost, fastest setup
Under 25 seats, growing fast VICIdial (managed) Avoid migration later
25-50 seats, no tech staff RingCentral or Talkdesk Good value, managed everything
25-50 seats, have tech staff VICIdial (self-managed) Crossover point, savings start
50-100 seats, outbound-heavy VICIdial (managed hosting) Clear cost winner, excellent dialer
100-200 seats, outbound-heavy VICIdial (managed + in-house) Massive savings, full control
200+ seats, outbound-heavy VICIdial (full in-house team) $500K+ annual savings
Any scale, omnichannel-heavy Genesys CX3 or Five9 Native multi-channel required
Any scale, compliance-critical Five9 or Genesys Pre-built certifications
Already on VICIdial, underperforming Professional VICIdial optimization Fix config before switching platforms

Last updated: March 2026. Pricing data from vendor websites, Platform28, CloudTalk, Capterra, and verified user reports. VICIdial costs from production deployments managed by ViciStack. All figures are estimates and should be verified with current vendor quotes.

Originally published on the ViciStack blog.