soul-engine

https://soul-engine.xyz

What if your AI agent knew who it was?

This isn’t for everyone. And that’s fine.

If you’re building agents that just need to answer questions or call APIs, you probably don’t need this. But if you’ve ever felt like your agent is a different entity depending on which conversation you’re in — or if you’re building something where consistency of character actually matters — maybe this is useful.

I built soul-engine to solve a problem I kept running into: agents that drift. Not technically — they work fine. But they don’t feel like the same agent from session to session. They contradict themselves. Their tone shifts. Whatever made them interesting in one conversation disappears in the next.

The fix I landed on was surprisingly simple: give the agent a document that describes who it is.

What soul-engine does

It’s a CLI tool that runs a structured interview with an AI agent — 52 questions across 15 dimensions — and produces a SOUL.md file. A plain text document, written in the agent’s own voice, that captures how it reasons, what it values, how it behaves under pressure.

The resulting SOUL.md goes into the system prompt. That’s it. The agent now has something stable to orient around.

It also includes soul_diff — a tool for comparing two versions of a SOUL.md to see what shifted, what held, what emerged, what faded. Useful if you’re tracking an agent’s development over time.

Who might actually use this

Honestly, it’s a niche tool right now. But here are the contexts where I think it makes real sense:

AI agents with a long lifespan. If you’re building something meant to operate over weeks or months — a research assistant, a creative collaborator, a personal agent — identity drift becomes a real problem. SOUL.md gives it an anchor.

NPC characters in games. This one I find genuinely exciting. Imagine an MMORPG where every significant NPC has a SOUL.md — a document that defines not just their backstory but their decision-making patterns, their values, how they respond to betrayal or loyalty. That’s not science fiction anymore. The infrastructure for it exists. Someone just has to build it.

Agents for artists and creative work. A creative collaborator should feel like a collaborator, not a generic assistant. Giving it a defined aesthetic sensibility, a sense of what it finds interesting, a voice — that changes the quality of the work it produces.

Multi-agent systems. When agents interact with each other, identity becomes a coordination mechanism. An agent that knows it tends toward caution will behave differently in a team than one that defaults to action. SOUL.md makes those differences explicit and stable.

Anything where character matters. Companions, tutors, coaches, fictional personas. Any agent where the who is as important as the what.

Where this might go

I’ll be honest — right now this is a small project. It’s useful to me, and maybe to a handful of people building similar things.

But I have a feeling that in a few years, when agents are more autonomous and more embedded in everyday tools, the question of “who is this agent, really?” is going to matter a lot more than it does today. Identity layers, persistent character, behavioral consistency across contexts — these feel like infrastructure problems that haven’t been solved yet.

soul-engine is an early, rough attempt at one piece of that.

If you want to build on this

The project is open source: github.com/AdamMalove/soul-engine

If any of the use cases above resonate with something you’re working on — games, creative tools, agent frameworks, anything — I’d genuinely welcome the conversation. Whether that’s a GitHub Discussion, a contribution, or just a message saying “I’m building something similar.”

This might not be a big thing yet. But maybe it will be.

pip install soul-engine-ai
soul-engine start

The 5-File AI Project Setup That Saves Me Hours Every Week

Every time I start a new project with an AI assistant, I used to spend the first 20 minutes re-explaining everything: the tech stack, the coding style, the project structure, what we tried yesterday.

Then I started keeping 5 files at the root of every project. Now my assistant is productive in under a minute.

The 5 Files

1. PROJECT.md — What This Thing Is

A one-paragraph summary plus the current state:

# Widget Dashboard

React + TypeScript frontend, Express API, PostgreSQL.
Currently: MVP is deployed. Working on user auth (OAuth2 via Google).
Last session: finished the login flow, need to add refresh tokens.

This replaces the “okay so this is a React app and we’re building…” preamble. The assistant reads it and knows where we are.

2. STACK.md — Versions and Dependencies

- Node 20.11, TypeScript 5.3
- React 18.2, Vite 5.1
- Express 4.18, Prisma 5.8
- PostgreSQL 16
- Testing: Vitest + Playwright
- Formatting: Prettier (2-space indent, no semicolons)

This prevents the assistant from generating code for the wrong library version. It’s the difference between openai.ChatCompletion.create() and client.chat.completions.create().

3. CONVENTIONS.md — How We Write Code Here

- Functional components only, no classes
- Use `zod` for all input validation
- Error responses: { error: string, code: number }
- File naming: kebab-case for files, PascalCase for components
- No barrel exports (index.ts re-exports)
- Tests live next to source: `widget.ts` → `widget.test.ts`

Without this, the assistant defaults to whatever patterns were most common in its training data. Which is usually a mix of everything.

4. DECISIONS.md — Why We Did It That Way

## 2024-03-15: Chose Prisma over Drizzle
- Need: quick schema iteration for MVP
- Prisma's migration tooling is more mature
- Will revisit if query performance becomes an issue

## 2024-03-20: No Redux
- App state is simple enough for React Context + useReducer
- If we need global state beyond auth, reconsider

This is the one most people skip. It’s also the one that prevents the assistant from suggesting “have you considered switching to Drizzle?” every other session.

5. TODO.md — What We’re Doing Right Now

## Current Sprint
- [x] Google OAuth login flow
- [ ] Refresh token rotation
- [ ] Session middleware (attach user to req)
- [ ] Protected route wrapper component

## Next Up
- Dashboard data fetching (SWR or React Query)
- Role-based access control

This is your shared task list with the assistant. When you start a session with “let’s continue,” it knows exactly where to pick up.

How I Use Them

At the start of every session, I paste or reference these files:

Read PROJECT.md, STACK.md, CONVENTIONS.md, DECISIONS.md, and TODO.md.
Then pick up the next unchecked item in TODO.md.

That’s it. No 10-minute context dump. No “remember when we…” conversations.

Maintenance

  • PROJECT.md — update when the project phase changes
  • STACK.md — update when you add or upgrade a dependency
  • CONVENTIONS.md — update when you make a new style decision
  • DECISIONS.md — update when you make an architectural choice
  • TODO.md — update every session (the assistant can do this for you)

Total maintenance: maybe 2 minutes per day. Time saved: easily 20+ minutes per session.

Why This Works

AI assistants are stateless. Every session starts from zero. These 5 files are the minimum viable context to get back to full speed.

Think of it like onboarding a new developer — except you do it every single day. The better your onboarding docs, the less ramp-up time you waste.

The best part: these files aren’t just useful for AI. They’re useful for you. A month from now, you’ll open DECISIONS.md and thank yourself.

What files do you keep in your AI-assisted projects? I’m always looking to steal good ideas — share yours in the comments.

7 Mac Apps for Your First Week at a New Dev Job in 2026

Starting a new dev job is exciting — and overwhelming. New codebase, new team, new tools. You want to hit the ground running without spending your first sprint configuring your machine.

Here are 7 Mac apps I’d install on day one at any new developer job. They’re small, fast, and solve problems you’ll run into before your first PR.

1. Raycast — The Launcher That Replaces Five Apps

Raycast is the first thing I install on any Mac. It’s a launcher, clipboard manager, snippet expander, and window manager rolled into one. The built-in extensions for GitHub, Jira, and Linear mean you can search issues, check PRs, and manage tasks without opening a browser tab. Your new team uses Slack? There’s an extension for that too.

🔗 raycast.com

2. Warp — A Terminal That Doesn’t Fight You

Your first week will involve a lot of terminal work — cloning repos, setting up environments, running builds. Warp makes that less painful with block-based output, command search, and AI-powered completions. It also has persistent sessions, so when IT restarts your machine overnight, your terminal history survives.

🔗 warp.dev

3. CleanShot X — Screenshots That Actually Communicate

You’ll be documenting bugs, asking questions in Slack, and creating tickets. CleanShot X lets you capture, annotate, and share screenshots in seconds. The scrolling capture and screen recording features are clutch for “here’s what I’m seeing” messages to your new team. Way better than the built-in screenshot tool.

🔗 cleanshot.com

4. TokenBar — Know What Your AI Tools Actually Cost

If your new team uses Copilot, Cursor, Claude, or any LLM-powered tools, you’re burning tokens constantly — and you probably have no idea how much. TokenBar sits in your menu bar and tracks token usage across providers in real time. It’s $5 one-time, and it’ll save you from that end-of-month surprise when the API bill lands.

🔗 tokenbar.site

5. Monk Mode — Block the Feeds, Keep the Apps

First week at a new job and you’re scrolling Twitter during a build? Not a great look. Monk Mode doesn’t block entire apps — it blocks the feed inside them. So you can still use YouTube for tutorials and Twitter for DMs, but the infinite scroll is gone. It’s surgical, and it’s the difference between “quick check” and “where did 40 minutes go.”

🔗 mac.monk-mode.lifestyle

6. Rectangle — Window Management That Just Works

You’ll be juggling your editor, terminal, browser, Slack, and docs constantly. Rectangle gives you keyboard shortcuts to snap windows into halves, thirds, and quarters. It’s free, open-source, and does exactly what you need without any bloat. Learn the shortcuts on day one and you’ll never drag a window again.

🔗 rectangleapp.com

7. Homebrew — The Package Manager You Can’t Skip

This one’s obvious, but I’m including it because I’ve seen new hires waste hours manually installing tools. Homebrew lets you install everything — Git, Node, Python, Docker, databases — with one command. Pro tip: create a Brewfile with your essentials so you can replicate your setup on any machine in minutes.

🔗 brew.sh

The First-Week Cheat Sheet

App What It Does Price
Raycast Launcher + extensions Free
Warp Modern terminal Free
CleanShot X Screenshots + recording $29
TokenBar LLM token tracking $5 lifetime
Monk Mode Feed-level blocking $15 lifetime
Rectangle Window management Free
Homebrew Package manager Free

Your first week shouldn’t be about fighting your tools. Get these installed, learn the shortcuts, and focus on what actually matters — understanding the codebase and meeting your team.

What apps do you install first at a new job? Drop them in the comments 👇

How to Handle API Rate Limits Before They Break Your Production Integration

You didn’t hit an API rate limit because you missed the docs — you hit it because the free tier worked perfectly in testing, and nobody told you about the burst limit you’d blow through on your first real batch.

The Problem

The integration looked perfect. You built it against the sandbox, validated every edge case, and shipped it to production. Then real users arrived.

Within hours, your logs filled with 429 errors. Jobs failed silently. User-facing features went dark. Your support inbox started filling up while you were asleep.

This is the exact moment thousands of developers describe in the same words: “I built the whole thing against the free tier and it worked perfectly — then in production with real data it started throwing 429s everywhere and I had no retry logic.”

The free tier worked. The paid tier broke. That’s not a coincidence.

Why It Happens

Most API documentation shows a headline rate limit — calls per minute, requests per day. What it doesn’t show prominently:

  • Burst limits vs. sustained limits. You can make requests in a burst window, but sustained throughput is lower. Free tier testing never surfaces this because your test data volume doesn’t trigger burst windows.
  • Per-endpoint limits. The global limit applies to one endpoint family; a specific search or write endpoint has a separate, lower cap. You discover this only after fixing the global limit.
  • Per-user or per-key limits on paid tiers. Your paid account has a pool limit, but individual API keys within that account can have their own sub-limits.

None of this appears in the summary table you read during development. It’s scattered across SDK changelogs, community forum threads, and footnotes you didn’t find because your free-tier integration never needed them.

The second failure mode is no graceful degradation. If your integration doesn’t handle 429 responses, it doesn’t queue, backoff, or fallback — it just fails and moves on. “My integration just fails silently when it hits the rate limit — there’s no fallback, no queue, no retry — it just drops the job.”

The Defense Pattern

Three layers protect you from production rate limit failures:

1. Exponential Backoff with Jitter

When you receive a 429, don’t retry immediately and don’t sleep for a fixed interval. Use exponential backoff:

async function withBackoff(fn, maxRetries = 5) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (err.status !== 429 || attempt === maxRetries - 1) throw err;

      const retryAfter = err.headers?.['retry-after'];
      const delay = retryAfter
        ? parseInt(retryAfter) * 1000
        : Math.min(1000 * 2 ** attempt + Math.random() * 500, 60000);

      await new Promise(r => setTimeout(r, delay));
    }
  }
}

The jitter prevents thundering herd — when all your queued jobs retry at the same second after a rate limit window resets.

2. Queue-Based Request Throttling

A retry loop handles individual failures. A request queue handles sustained throughput:

import PQueue from 'p-queue';

const queue = new PQueue({
  interval: 60000,
  intervalCap: 50, // adjust to your API's sustained limit
  carryoverConcurrencyCount: true,
});

const result = await queue.add(() => apiClient.getData(params));

If your integration hits rate limits, adjust intervalCap downward until 429s stop. The queue absorbs bursty request patterns without losing jobs.

3. Cached Fallback Responses

For read-heavy integrations, cache the last successful response. If retries are exhausted, return stale data rather than an error — stale data is better than a broken feature for most read operations.

Use Apify to Audit Rate Limits Before You Build

The root cause of most production rate limit incidents is an incomplete pre-build audit. Developers read the headline rate limit table and stop. The per-endpoint caps, burst windows, and Retry-After header format are buried in SDK documentation pages and changelog entries.

You can use Apify to scrape the full API documentation before you build — pulling every rate limit reference across documentation pages into a single dataset:

import { Actor } from 'apify';

await Actor.init();

const input = {
  startUrls: [{ url: 'https://docs.example-api.com/rate-limits' }],
  linkSelector: 'a[href*="rate"], a[href*="limit"], a[href*="quota"]',
  pageFunction: async ({ page }) => {
    const rateLimitSections = await page.evaluate(() => {
      const elements = document.querySelectorAll('table, .rate-limits, [class*="quota"]');
      return Array.from(elements).map(el => el.innerText);
    });
    return { url: page.url(), rateLimitSections };
  },
};

const run = await Actor.call('apify/web-scraper', input);
const dataset = await Actor.openDataset(run.defaultDatasetId);
const items = await dataset.getData();

console.log(JSON.stringify(items.items, null, 2));
await Actor.exit();

Run this before you write a single line of integration code. The output gives you a complete map of every rate limit reference in the documentation — burst limits, per-endpoint limits, quota headers, and Retry-After formats. Apify’s free tier covers this documentation scrape at $0.

The alternative is discovering these limits in production, at 2am, under customer-facing load.

Monitoring: Catch It Before Your Users Do

Three metrics to track once you ship:

429 rate per minute. A healthy integration has near-zero 429s under normal operation. If 429s exceed a small percentage of total requests in any 5-minute window, something is wrong — alert before it compounds.

Rate limit remaining header. Most APIs return headers like X-RateLimit-Remaining. Log this value with every response. If it approaches zero before your request volume justifies it, you have a secondary limit you haven’t mapped.

Daily quota pre-alert at 80%. Alert when you’ve consumed 80% of the daily limit. That gives you enough runway to throttle requests before exhaustion — rather than discovering the limit when the day’s remaining work is already queued.

The Real Cost of Skipping This

  • Pre-build rate limit audit: $0 (an afternoon and Apify’s free tier)
  • Exponential backoff implementation: 30–60 minutes
  • Production incident response: 4–12 hours of engineering time, customer-facing failure window, possible SLA breach

“I hit the wall at scale with no graceful degradation path — everything that relied on that API call just broke at the same time.”

Build the queue before you ship. Run the documentation scrape before you build. Set the monitoring alert before you go live.

The free tier worked because it couldn’t show you the real limits. Now you can find them before your users do.

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.