2026-07-12 18:31:27
Every logistics and field-sales team runs the same expensive process: a driver photographs a receipt into a WhatsApp group, and a back-office clerk manually types the invoice number, total, and date into a spreadsheet. Hundreds of receipts a week = transcription errors and thousands of wasted hours.
AI vision models kill that bottleneck. Here's the pipeline that turns a blurry field photo into clean structured data in seconds.
OCR reads characters. Modern vision models (Claude Vision, Gemini Vision, GPT-4 Vision) read structure — they distinguish a tax ID from a total, and a date from an amount, even on crumpled, angled, or poorly lit receipts. No brittle per-vendor parsers.
WhatsApp image → Apps Script doPost → forward to vision model
→ model returns JSON { InvoiceNumber, TotalAmount, VendorName,
Date, Category, confidence_score }
→ confidence routing:
> 90 → auto-append to ledger
70–90 → flag for human review
< 70 → ask driver to re-photo
→ write row to Google Sheet (+ link to original image)
→ auto WhatsApp confirmation to driver
The confidence_score is the whole trick — it's what stops bad extractions from silently polluting your ledger.
Pattern: Gemini for the first pass, escalate only low-confidence cases to Claude / GPT-4o.
~500 receipts/week: vision API $10–40 + WhatsApp API $30–60 + Apps Script free = ~$40–100/month. Versus a clerk at ~25 hrs/week = $2,000–4,000/month in loaded labor. Per-receipt cost: $0.005–0.02.
Accuracy: 92–97% on legible receipts, 75–85% on handwritten/damaged — hence the confidence routing.
The complete pipeline, categorization, and privacy controls are in the full guide on the MageSheet blog.
Built by the MageSheet team.
2026-07-12 18:21:51
"We shipped the safety work" is a feeling, not a fact. Before you hand a shared, governed system to a team, the only thing that converts that feeling into the truth is a structured adversarial review that verifies claims against live state — not against the design doc, and not against the diff.
On 2026-07-09 that review ran against an internal team knowledge system being prepared to open to six people all at once. It validated two judgment calls, broke three, and produced a list of eighteen real risks the plan had not named. The verdict: do not go all-at-once, and do not email anyone a token, until the first gate clears.
That halt was the correct output. This is the story of the method that produced it, the three assumptions it demolished, and the fixes that shipped the same day — every one of which enforces a single boundary: the model proposes; the deterministic system owns identity, trust, and durable state.
The subject is a "second brain": a governed team knowledge base. Content is captured, run through a deterministic govern pipeline, and — if it survives the gates — promoted into durable memory. Every state change writes a receipt into an append-only, hash-chained ledger, so the store is tamper-evident: you can recompute the chain and detect any row that was altered out of band. Two surfaces front it. A private, tailnet-bound HTTP API is the control plane — it holds the write-gate, the tenant guard, and the audit-actor stamp. A Claude Code plugin (an MCP server) is how people actually talk to it from their editor. Six teammates were about to get access: two admins, four members.
A "safety before people" pass had already landed. It was real work, not theater: bearer tokens were hashed at rest instead of stored as plaintext; per-user tokens were minted so each teammate carried a distinct identity; an onboarding runbook was written; several hardening items were verified against the running service. On paper, the box marked "make it safe for a team" was checked.
The instinct to stop there is the trap. A safety pass tells you what you did. It does not tell you what you have — the residual risk, the assumptions you smuggled in, the interactions between a fix and the rest of the system. Those only surface when someone hostile to the plan goes and looks at the live thing. So before flipping the switch, that is exactly what happened.
An adversarial review checks claims against the running system, not the design doc and not the diff. Independent reviewers each inspect the live service, its databases, and its deployment through a different lens — security, operations, reliability, integrity, rollout, threat model — so an assumption that is locally true but globally false has nowhere to hide.
Six independent engineer agents ran in parallel on the Fable model. Each got the same design brief and one distinct lens. The instruction that made the difference was not "review the plan." It was verify against real code and live state: read the repos, query the live databases, recompute the hash chain yourself, check org membership yourself, inspect the running service unit yourself. A review that only reads a diff can confirm the diff does what it says. It cannot catch a claim that is locally true and globally false — and all three of the broken assumptions were exactly that shape.
| Lens | Question it was forced to answer |
|---|---|
| Data integrity & correctness | Recompute every hash. Does the store actually verify? Can any path write a durable row without its receipt? |
| Security & secrets | Where do the still-live secrets exist, in every location, including backups? |
| Deployment & operations | What does the service actually run from? What happens on crash-restart or rollback? |
| Reliability & concurrency | Who can write concurrently, and what lock — if any — serializes them? |
| Rollout completeness & teammate UX | What does a real member experience on a bad token, a dead API, or off-network? |
| Adversarial / threat model | Assume a leaked member token. What can it forge, escalate, or clear? |
Independence is the other half of the method, and it is not optional. One reviewer with a six-item checklist shares one set of blind spots across all six items — if their mental model of the system is wrong in a given place, it is wrong for every lens they apply. Six agents that never see each other's work cannot collude into a shared assumption. The security lens does not know the reliability lens exists, so it has no reason to defer to it, and that is exactly why one of them looked at the backups while another looked at the write lock. Deduplication happens after independent discovery, never before.
The integrity lens recomputed all 2,186 candidate content hashes rather than trusting the "verified" label. The security lens enumerated every place a live secret's plaintext could exist rather than trusting "we hashed the file."
That is the entire discipline: independent lenses, each grounded in the running system. It cost six agent-runs and the humility to let them contradict the plan. It was worth it, because it broke three things everyone believed were done.
Before the breakage, credit where the design earned it — because a review that only ever confirms your fears is as miscalibrated as one that only ever flatters you. The review's two headline validations were calls of restraint: it endorsed holding the riskiest feature — the auto-govern path that drives broken call #3 below — as design-only rather than rushing it into the rollout, and it blessed the sequencing that checked migration safety before restarting the live service. In both cases, not shipping yet was the correct instinct.
Two foundational design bets also held up under every lens, and they are worth naming because the fixes below lean on them.
The first is per-user token identities. Minting a distinct token per teammate, rather than a shared team secret, looked like extra onboarding friction at the time. It is what makes the entire server-side intake override (below) possible: every request carries a resolvable actor, so the server has something to re-derive trust and authorship from. A shared secret would have left it nothing to distinguish — you cannot own identity you cannot tell apart. The friction bought the security model.
The second is the append-only, hash-chained receipt ledger itself. The integrity lens recomputed the whole chain and confirmed it does what it claims: alter any promoted row out of band and the recomputation detects it. The atomicity fix that follows does not replace that design — it protects it, by closing the one window where a durable row could exist without its receipt. A weaker audit design would have had nothing worth protecting.
Naming what held is not politeness. It is calibration: it tells you the review's "broken" verdicts are signal, not a reviewer reflexively torching everything to look thorough.
This is the spine of the story. Two shipped decisions survived scrutiny; three did not — and the three that fell are the transferable lessons, because each was a locally reasonable call that the live state proved wrong.
The safety pass hashed the existing tokens in place. It kept the same secret values and just stored their hashes instead of their plaintext, specifically to avoid the churn of re-issuing tokens to everyone. Locally, that reasoning is sound: the credential file no longer holds plaintext, so a read of that file at rest yields nothing usable.
The security lens went and looked at every place those secret values lived. Retained encrypted backups — one local, one off-host, and at least one target with no retention limit — already held the plaintext of those same still-live secrets, captured before the hashing change. Hashing the file protected it going forward, at rest, in one location. It did nothing for a secret whose plaintext sits in a backup you can restore.
Re-hashing a value you have already exposed is not protection. Retiring an exposed secret requires credential rotation — a genuinely new value — plus purging the exposure. If the plaintext still exists anywhere you can restore from, the old secret is live, and hashing its current home is a false sense of safety.
The fix was not "hash harder." It was rotate the tokens to new values and treat the pre-hash backups as compromised. Hash-at-rest was necessary; it was not sufficient, and believing it was sufficient is precisely the failure a live-state review exists to catch.
To let the system's founder query the brain from any editor session, the plugin was enabled in user-scope local mode. Convenient, and locally reasonable: one person, their own machine, direct access. The reliability lens asked the question the plan never did — what does local mode actually do?
Local mode runs in-process as owner/admin. It does not go through the HTTP API. That means it bypasses the entire control plane: the write-gate, the tenant guard, and the audit-actor stamp all live on the API surface, and local mode is a side door around all three. Worse, "any editor session" is not one writer. Up to roughly eleven concurrent interactive sessions became unlocked writers — none of them taking the single-writer file lock that the nightly cron jobs depend on to serialize their writes.
The concrete failure: a backup taken mid-write, with no lock held, can capture a torn state. Restore it and the brain's own tamper-evidence machinery reports TAMPER DETECTED — not because anyone tampered with anything, but because the hash chain was snapshotted between two writes. The safety mechanism fires on its own operator.
A design built for one writer and one user silently becomes a concurrency-and-authorization surface the instant you make it multi-session. The reach goal — "query from anywhere" — was right. Routing it around the single writer was wrong. It should have gone through the API's single writer, inheriting the write-gate, the tenant guard, the audit stamp, and the lock.
A planned feature — the next thing on the roadmap — would DELETE candidate proposals after they were governed. The reasoning read cleanly if you looked only at the write path: the candidate has been governed, its outcome is recorded, so the row is spent; delete it to keep the table lean.
The integrity lens read the data-classification doc and every consumer of that table, not just the code that does the delete. The candidates table is documented as insert-only, immutable, and a non-reproducible source of truth. Three consumers depend on that:
The plan proposed a "second run is a no-op" test to prove safety. That test would have passed while missing the loop entirely, because it seeded the table rather than the upstream spool files — so the dedupe it exercised was not the dedupe the loop breaks. A green test against the wrong fixture is worse than no test; it launders the bug.
A design that deletes durable rows must be checked against the data-classification doc and every consumer of that data — review queue, re-ingest idempotency, provenance back-links — not just the code path that performs the write. "Is this row still needed by the thing that wrote it?" is the wrong question. "Who else reads this row, and what is the only copy of it?" is the right one.
Deduplicated across the six engineers, the findings collapsed to eighteen distinct risks. Where two lenses hit the same issue, severity was taken as the max across engineers — you do not average a "critical" with a "medium" and call it "high." A flat wall of eighteen findings is not actionable; it is a demoralizing to-do list with no critical path. So the register was gated into three tiers, and the gating is what made it usable.
| Gate | Meaning | Representative risks |
|---|---|---|
| Gate 0 | Must clear before any teammate onboards or any token email is sent | Rotate exposed secrets + purge backups; hash tokens at rest; immutable deploy with lockout guard; durable revoke-by-actor |
| Gate 1 | Must clear before the auto-govern feature ships | Server-side candidate-intake override; atomic promotion; the delete-on-govern redesign |
| Gate 2 | Hardening — soon, but non-blocking | Additional plugin observability; expiry policy; ancillary rate limits |
Gating converts "here are eighteen problems" into "here is the one blocking set standing between you and a safe rollout." It also makes the halt legible: the verdict "do not go all-at-once and do not email tokens until Gate 0 clears" is a statement about a specific tier, not a vague unease. No emails were sent. No teammate was onboarded. That was the correct state to be in, and the gate model is what let everyone agree on it in one sentence.
Gate 0 and the first Gate 1 items landed the same day. Each is a small, well-reasoned change. Shown below are the ones where the design decision is the point.
The token registry now accepts an already-salted scrypt$salt$hash value in a record's token field and uses it verbatim, so the credential file can store hashes instead of plaintext bearer secrets. scrypt is a deliberately expensive, memory-hard key-derivation function — a stolen hash is costly to brute-force, unlike a bare SHA-256 digest. Plaintext still works, for operator convenience — and a value that merely looks hashed but has non-hex segments falls back to being hashed as plaintext rather than being trusted as a hash.
// A registry record's token field may already be a hashed secret.
// Accept a well-formed scrypt$salt$hash verbatim; otherwise hash it as plaintext.
function resolveToken(field) {
const parts = field.split('$');
const isHashed =
parts.length === 3 &&
parts[0] === 'scrypt' &&
/^[0-9a-f]+$/.test(parts[1]) && // salt segment must be hex
/^[0-9a-f]+$/.test(parts[2]); // hash segment must be hex
return isHashed ? field : hashToken(field);
}
Necessary — but, per broken call #1, not sufficient on its own. This fix protects the file at rest. It does nothing about the plaintext already sitting in backups. Rotation plus purge was the other half, and shipping the hash without the rotation would have been the exact false-safety the review flagged. The tension is the lesson: a real fix and an incomplete fix can look identical in a diff.
The service had been running from a mutable working checkout. That is three latent failures at once: any rebuild in that repo mutates the live service, a crash-restart can relaunch from a torn or feature-branch build, and there is no immutable artifact to roll back to. And there was a sharper trap hiding inside it. Rolling the checkout back past the token-hashing change would rebuild the pre-hash registry, which would then double-hash the now-hashed credential file — and lock out all six users at once.
The fix builds immutable, self-contained release directories behind an atomic current symlink, and refuses to deploy anything older than the migration that would cause the lockout.
# Floor guard: never deploy a commit that predates the lockout-inducing token-hash migration.
FLOOR_TAG="token-hash-floor"
if ! git merge-base --is-ancestor "$FLOOR_TAG" "$TARGET_REF"; then
echo "REFUSE: $TARGET_REF predates $FLOOR_TAG — deploying it would rebuild the" \
"pre-hash registry, double-hash the credential file, and lock out every user."
exit 1
fi
# Build an immutable, self-contained release dir: no .git, frozen deps, built once.
REL="/opt/brain/releases/$(date -u +%Y%m%dT%H%M%SZ)-$(git rev-parse --short "$TARGET_REF")"
mkdir -p "$REL"
git archive "$TARGET_REF" | tar -x -C "$REL"
( cd "$REL" && install_frozen_deps && build )
# Lockout preflight: the built artifact MUST contain the hash parser, or it will double-hash.
grep -q 'scrypt' "$REL/dist/token-registry.js" || { echo "REFUSE: hash parser missing from build"; exit 1; }
PREV_REL="$(readlink -f /opt/brain/current || true)" # capture the current target FIRST, for rollback
ln -sfn "$REL" /opt/brain/current # atomic promotion via symlink swap
systemctl restart brain-api
Then a health gate with auto-rollback, so a bad release un-ships itself:
if ! curl -fsS --max-time 5 "$HEALTH_ENDPOINT" >/dev/null; then
echo "post-restart health gate failed — rolling back to previous release"
ln -sfn "$PREV_REL" /opt/brain/current
systemctl restart brain-api
exit 1
fi
The git merge-base --is-ancestor <floor-tag> <target> check is a named, transferable ops pattern: a floor guard against a lockout-inducing rollback. Any time a migration makes older code actively dangerous to redeploy — not just wrong, but destructive — pin a floor tag at the migration and refuse to deploy beneath it. Immutability gives you a rollback target; the floor guard makes sure the rollback target can't itself be the disaster.
Tokens had no expiry, and the only revoke path was in-memory — lost on restart. And now that tokens are hashed at rest, an admin no longer holds any teammate's plaintext bearer secret, so "revoke by value" is impossible for the realistic incident: a teammate's laptop was stolen. You cannot revoke a secret you deliberately no longer possess.
So revocation keys off the audit identity the token already carries, and persists to an append-only file read at boot.
// Tokens are hashed at rest, so no admin holds a plaintext bearer secret to revoke by value.
// Revoke by the audit identity the token carries; persist it so it survives a restart.
function revokeByActor(actor, reason) {
revoked.add(actor); // in-memory guard, effective immediately
appendFileSync(REVOCATION_LIST, // append-only ban-list: durable + audit trail
JSON.stringify({ actor, reason, at: new Date().toISOString() }) + '\n');
}
// At boot, replay the ban-list so a revoked actor stays revoked across restarts.
function loadRevocations() {
for (const line of readLines(REVOCATION_LIST)) revoked.add(JSON.parse(line).actor);
}
The design choice worth naming: a separate append-only ban-list file was chosen over mutating the source token file on every incident. Rewriting the credential store during an active incident — the highest-stress moment — is how you fat-finger a lockout. Append-only is safer than rewrite, and it doubles as a revocation audit trail: who was revoked, when, and why, in order, forever.
This is the clearest instance of the whole thesis. In team mode the client built the entire candidate object, and the server trusted it verbatim. That means a member — or a leaked member token — could self-assert trustLevel: 'high' to clear a minimum-trust gate, forge the author, set an arbitrary tenant, and clear the "potential secret" flag on their own content. And intake wrote no audit event, so none of it left a trace.
The fix: the server re-derives the fields that decide trust, authorship, and tenancy from the bearer-token identity, ignoring the body for exactly those fields, and writes a provenance receipt on every proposal.
// TEAM MODE: the client builds the whole candidate, but the server trusts NONE of it
// for the fields that decide trust, authorship, and tenancy. Re-derive from the token.
function intake(reqBody, auth) {
const candidate = {
...reqBody,
trustLevel: auth.trustLevel, // NOT reqBody — client cannot self-assert 'high'
author: auth.actor, // NOT reqBody — no forging provenance
tenant: auth.tenant, // NOT reqBody — no cross-tenant writes
potentialSecret: scan(reqBody.content), // server re-scans; client cannot clear the flag
};
writeReceipt('candidate.intake', candidate, auth); // every proposal leaves a receipt
return store(candidate);
}
Two alternatives were considered and rejected:
The override wins because it is the smallest change that makes the guarantee true: the client proposes a candidate; the server decides what that candidate is. Identity, trust, and tenant are server-owned. That sentence is the whole security model in miniature.
The promotion path — moving a governed candidate into durable memory — did roughly five separate autocommits: a supersession update and its event, the memory insert, the graph-edge links, and the "promoted" receipt. A kill mid-promote — the compile cron hitting its timeout, or a plain SIGTERM — could leave a promoted memory with no "promoted" receipt. A durable row without its audit receipt violates the product's core promise — every state change has a receipt — and it never self-heals, because nothing re-derives a receipt for a row that already exists.
The fix wraps the whole write block in one BEGIN IMMEDIATE transaction — SQLite's mode for taking the write lock up front instead of deferring it to the first write statement — so the memory and its receipt commit together or not at all.
// Memory row + its "promoted" receipt must commit atomically, or the
// append-only-receipts promise breaks and never self-heals.
function promote(candidateId, auth) {
const db = this.getDb(); // read-only getter over the shared connection
db.exec('BEGIN IMMEDIATE');
try {
supersedePrior(candidateId); // supersession update + its event
const memId = insertMemory(candidateId);
linkGraphEdges(memId);
writeReceipt('promoted', memId, auth); // nested audit inserts degrade to SAVEPOINTs here
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}
Two design details carry their own rationale. First, the shared DB connection was exposed via a read-only getter rather than threading a new db handle through the constructor — so every existing caller's signature stays unchanged, keeping the blast radius small. Second, the nested audit inserts each open their own BEGIN IMMEDIATE; inside the outer transaction those degrade to savepoints, which preserves the audit chain's anti-fork guarantee rather than fighting it. And the negative control matters: with the outer transaction bypassed, the atomicity tests fail — the memory is orphaned — and with it in place they pass. The test catches the real regression, not a tautology that would pass either way.
The plugin got its own Gate 0 work, because it is the surface six people will actually touch:
flock(2) advisory lock the cron jobs use — a real kernel flock(2) on the same file, so it interoperates with the cron's flock(1). A PID-lockfile library was rejected precisely because it shares no kernel lock with flock(1); two "locks" that don't see each other are not a lock.
// Local-mode writers take the SAME kernel lock the cron holds (flock(2) <-> flock(1)).
const fd = openSync(LOCK_PATH, 'r');
flockSync(fd, 'ex');
try {
await writeToBrain(payload);
} finally {
flockSync(fd, 'un');
}
brain_status probe answers "am I connected, in which mode, and do I have a token?" — the three things a confused teammate needs before they can even ask for help.None of this was free, and pretending otherwise would undercut the point.
Read the Gate 0 and Gate 1 fixes together and they are not five unrelated patches. They are five expressions of one boundary:
The model — or the client, or the convenient side door — proposes. The deterministic system owns identity, trust, and durable state. Every place the old design let the proposer also decide what its proposal was, the review found a hole, and every fix closed it by moving that decision back to the deterministic side. That boundary is not specific to this system. It is the load-bearing wall of any governed AI system where something upstream is allowed to be creative and something downstream has to be trustworthy.
And there is a standing invariant the review left behind, worth more than any single fix: all-at-once is the right rollout for six people — but only after one person has walked the entire path end-to-end and Gate 0 is clear. Confidence at team scale is earned by one proof at individual scale, not asserted by a completed checklist.
The meta-lesson is the method itself. Six independent lenses, each forced to verify against live state rather than read the design, are what caught "the backup still holds the plaintext" and "local mode runs as admin, in-process, unlocked." A review that only read the diff would have blessed all three broken calls — because in the diff, all three looked done. The difference between shipping the safety work and knowing you shipped it is whether someone went and looked at the running thing with hostile intent, before the people arrived.
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Adversarial Review: The Six Lenses That Halted a Rollout",
"description": "A six-lens adversarial review checked a team knowledge system against live state, broke three shipped assumptions, and gated 18 risks to halt the rollout.",
"author": { "@type": "Person", "name": "Jeremy Longshore" },
"publisher": {
"@type": "Organization",
"name": "Start AI Tools",
"logo": { "@type": "ImageObject", "url": "https://startaitools.com/favicon.ico" }
},
"url": "https://startaitools.com/posts/adversarial-review-before-team-rollout/",
"datePublished": "2026-07-09",
"keywords": "adversarial review, security review, governed AI system, team rollout, authentication, claude-code",
"articleSection": "Architecture"
}
2026-07-12 18:21:26
docker run con Exited (1) en Raspberry Pi
El código de salida 1 indica que el proceso principal del contenedor terminó con un error genérico. En Raspberry Pi, los casos más comunes son:
amd64 (x86_64), pero Raspberry Pi usa armhf o arm64.ENTRYPOINT o CMD del contenedor intenta ejecutar un binario compilado para otra arquitectura./dev/*) o permisos de ejecución.--net = host: El espacio de nombres de red host requiere privilegios elevados y puede fallar si el contenedor no tiene --privileged.🔍 Clave diagnóstica: El hecho de que funcione en una VM de Raspberry Pi (probablemente emulando x86_64 con QEMU) pero no en el hardware físico confirma que el problema es arquitectura.
# Arquitectura del host (Raspberry Pi)
uname -m
# Arquitectura de la imagen
docker inspect --format='{{.Architecture}}' myimage
Si el host muestra armv7l o aarch64, pero la imagen muestra amd64, la arquitectura no coincide.
docker run --net=host -it --rm myimage
⚠️ Nota: Quita
-d(background) y-t(TTY) para ver los logs en tiempo real. Si el error es por arquitectura, verás algo como:standard_init_linux.go:211: exec user process caused: no such file or directory
# Ejemplo: imagen oficial de Python compatible con ARM
docker run --net=host -d -t python:3.11-slim
# Clona o copia tu Dockerfile en la Pi
docker build -t myimage .
# Instala soporte multiarquitectura
sudo apt install qemu-user-static
# Registra el binario en Docker
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
# Ahora puedes ejecutar imágenes x86_64 (lento)
docker run --net=host -d -t myimage
--net=host
El espacio de nombres host no requiere espacios en el argumento:
❌ --net = host
✅ --net=host
El error de sintaxis puede causar que Docker interprete
=como parte del valor, fallando silenciosamente o lanzando un error no obvio.
Si el contenedor necesita acceso a hardware (GPIO, I2C, etc.), añade:
docker run \
--net=host \
--privileged \
-v /dev:/dev \
-d -t myimage
⚠️
--privilegedes peligroso en producción. Usa--cap-add=SYS_ADMINo dispositivos específicos (--device=/dev/i2c-1) si solo necesitas acceso limitado.
# Paso 1: Verifica arquitectura
docker inspect --format='{{.Architecture}}' myimage
# Paso 2: Ejecuta en primer plano para debug
docker run --net=host -it --rm myimage
# Paso 3: Si falla por arquitectura, reconstruye o usa multiarch
# Opción recomendada: construye localmente
docker build -t myimage-arm .
# Paso 4: Ejecuta con sintaxis correcta y permisos necesarios
docker run --net=host -d -t myimage-arm
Crea un script de inicio rápido:
#!/bin/bash
ARCH=$(uname -m)
case "$ARCH" in
armv7l) IMAGE_TAG="arm32v7/myimage" ;;
aarch64) IMAGE_TAG="arm64v8/myimage" ;;
*) echo "Arquitectura no soportada: $ARCH"; exit 1 ;;
esac
docker run --net=host -d -t "$IMAGE_TAG"
✅ Resultado esperado: El contenedor se inicia y permanece en ejecución (
docker psmuestra estadoUp). Si sigue fallando, revisa los logs condocker logs <container_id>.
2026-07-12 18:19:12
Originally published at ffmpeg-micro.com
You're building a product. Somewhere in the spec, there's a video feature: thumbnail generation, format conversion, maybe clip trimming. You don't want to learn FFmpeg to ship it. You shouldn't have to.
This post walks through connecting FFmpeg Micro's MCP server to Cursor so your AI assistant can write video processing code for you. No FFmpeg knowledge required.
Video processing is the feature that kills momentum. You either spend days wrestling with FFmpeg flags and codec options, or you pay $200/month for an enterprise video API you don't need yet. Most founders just skip the video feature entirely.
That's the wrong tradeoff.
The video feature is often the thing that makes your product feel real. A course platform without video upload is a Google Doc. A social app without clip trimming is a text feed. Skipping video doesn't save time. It delays the moment your product becomes compelling.
The problem isn't that video processing is hard. It's that the tooling assumes you already know what you're doing. FFmpeg has over 400 flags. The documentation reads like a systems manual from 1998. And every Stack Overflow answer assumes you understand codecs, containers, and pixel formats.
You don't need to understand any of that to ship a video feature.
FFmpeg Micro has an MCP (Model Context Protocol) server that exposes video processing tools to AI assistants. Cursor supports MCP natively. Once you connect the two, you can describe video operations in plain English and Cursor writes the integration code for you.
No FFmpeg docs. No Stack Overflow rabbit holes. Just tell Cursor what you want, and it calls the API.
The MCP server gives Cursor access to six tools: creating transcode jobs, checking job status, listing jobs, canceling jobs, getting download URLs, and a convenience tool that handles the full create-poll-download cycle in one shot. Cursor sees the tool descriptions, understands the parameters, and generates the right API calls for your codebase.
Create a free account at ffmpeg-micro.com if you don't have one yet.
Add the MCP server to your project. Create a .mcp.json file in your project root:
{
"mcpServers": {
"ffmpeg-micro": {
"type": "http",
"url": "https://mcp.ffmpeg-micro.com"
}
}
}
That's it. No API keys to copy, no environment variables to configure. The FFmpeg Micro tools will appear in Cursor's tool panel.
If you prefer using an API key instead of OAuth (useful for CI or automation), you can grab one from your dashboard and pass it as a header:
{
"mcpServers": {
"ffmpeg-micro": {
"type": "http",
"url": "https://mcp.ffmpeg-micro.com",
"headers": {
"Authorization": "Bearer your_api_key_here"
}
}
}
}
Once the MCP server is connected, you just talk to Cursor. It generates the API calls your app needs. Here are three common scenarios.
"Convert this user-uploaded video to MP4 with reasonable quality"
Tell Cursor what you need, and it generates something like this:
const response = await fetch('https://api.ffmpeg-micro.com/v1/transcodes', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FFMPEG_MICRO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
inputs: [{ url: videoUrl }],
outputFormat: 'mp4',
preset: { quality: 'high', resolution: '1080p' }
})
});
const job = await response.json();
You didn't need to know that "reasonable quality" maps to a CRF of 18. Cursor figured that out from the MCP tool descriptions.
"Trim this video to the first 30 seconds for a preview"
const response = await fetch('https://api.ffmpeg-micro.com/v1/transcodes', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FFMPEG_MICRO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
inputs: [{ url: videoUrl }],
outputFormat: 'mp4',
options: [
{ option: '-ss', argument: '0' },
{ option: '-t', argument: '30' },
{ option: '-c', argument: 'copy' }
]
})
});
The -ss, -t, and -c copy flags are standard FFmpeg. But you didn't write them. Cursor knew what "trim to 30 seconds" means in FFmpeg terms because the MCP server told it which options are available.
"Add a text overlay with the product name"
const response = await fetch('https://api.ffmpeg-micro.com/v1/transcodes', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FFMPEG_MICRO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
inputs: [{ url: videoUrl }],
outputFormat: 'mp4',
options: [
{
option: '@text-overlay',
argument: {
text: 'Built with Acme',
style: { fontSize: 48, x: '(w-text_w)/2', y: 'h-80' }
}
}
]
})
});
The @text-overlay virtual option handles all the drawtext filter complexity behind the scenes. If you've ever tried to write an FFmpeg drawtext command by hand, you know why this matters.
Each of these examples polls the job status and grabs a signed download URL when the transcode finishes. Cursor generates the polling logic too. You can also use the transcode_and_wait MCP tool during development to handle the full cycle in one call.
The comparison isn't really close.
Learning FFmpeg means weeks of reading docs, fighting codec compatibility issues, memorizing flags, and debugging cryptic error messages. Every new video feature means another research session.
Cursor plus FFmpeg Micro means you describe what you want, get working code, and ship today. The MCP server acts as a bridge between natural language and the API. Cursor doesn't guess at the integration. It has the full tool specification and generates correct calls.
You can always learn FFmpeg later when you need fine-grained control over encoding parameters or want to optimize for specific use cases. Right now, you need to ship.
And if you're already using VS Code with GitHub Copilot, the same MCP server works there too. You can also build autonomous video processing agents with Claude Desktop using the same setup.
Do I need to know FFmpeg commands?
No. That's the whole point. Cursor's AI understands the FFmpeg Micro API through the MCP server. Describe what you want in plain English.
What does this cost?
FFmpeg Micro has a free tier. You pay per minute of video processed after that. Most MVPs process under an hour of video per month, which keeps costs minimal.
Can I use this with other IDEs?
The MCP server works with any MCP-compatible tool. Claude Desktop, VS Code with GitHub Copilot, Windsurf, Warp. FFmpeg Micro has setup guides for all of them.
What if I need something FFmpeg Micro doesn't support?
The API accepts raw FFmpeg options for advanced use cases. If it's a valid FFmpeg flag, you can pass it through the options array. The MCP server documents which flags are supported, so Cursor knows what's available.
FFmpeg Micro's free tier gives you enough processing for most MVP video features. Create an account, drop the MCP config into your project, and ship that video feature today.
2026-07-12 18:18:28
I spent three months measuring multi-cloud DNS failover for my final-year dissertation. The most useful thing I learned contradicts common advice, so I'm sharing the numbers.
When the AWS US-East-1 outage hit in October 2025, half the internet went down with it. The standard answer to "how do I survive a cloud outage?" is DNS-based failover: run a standby in another cloud, point a Route 53 health check at your primary, and let DNS redirect traffic when it fails.
And the standard tuning advice that comes with it: lower your TTL to fail over faster. Set it to 60 seconds instead of 300 and you'll recover five times quicker. It sounds obvious. It's also wrong. Or at least, it's only a small part of the story.
I built a deliberately minimal setup so the measurements would be clean:
Then I killed the primary. Nine times, across three TTL configurations (60s, 120s, 300s), measuring from multiple DNS resolvers, plus three failback runs in the other direction. Every run logged to CSV.
Failover time (RTO) has two separate components, and most advice mixes them up:
1. Detection time: how long Route 53 takes to notice your primary is dead. In my runs this came out at ~48 seconds, and here's the key part: it was constant across every TTL configuration. TTL=60 and TTL=300 detected the failure in the same time, because detection is governed by Route 53's internal polling and quorum logic, not by the TTL advertised to resolvers. To reduce it, you need to adjust the health check interval and the failure threshold.
2. Propagation time: how long resolvers take to pick up the new record. This is where TTL matters, but it turned out to be resolver-dependent, and sometimes dramatically so. The clearest example: at TTL=300s, Cloudflare's resolver kept oscillating for over 325 seconds after the failover, while Google DNS stabilised much faster. Same record, same TTL, very different behaviour depending on who resolves your users' queries.
The full numbers:
| Metric | Result |
|---|---|
| Mean failover RTO (all TTL configs, n=9) | 48.0 s |
| RTO at TTL=60s (mean ± SD) | 46.3 s ± 0.6 s |
| RTO at TTL=120s (mean ± SD) | 45.3 s ± 2.9 s |
| RTO at TTL=300s (mean ± SD) | 52.3 s ± 7.6 s |
| Failback RTO at TTL=60s (mean ± SD) | 35.3 s ± 3.1 s |
| Route 53 detection time | ~48 s, TTL-independent |
| RPO | 0 s (stateless workload) |
| Total infrastructure cost | < €5 across all three iterations |
Notice the pattern: TTL=60 and TTL=120 land within noise of each other, because detection dominates both. Only at TTL=300 does the RTO climb, and its standard deviation more than doubles, because now you're at the mercy of resolver caching. RPO was zero, but only because the app was stateless. A real application with a database would not get the same result.
If your failover is taking 60+ seconds and you respond by dropping the TTL from 300 to 60, you'll be disappointed. You're optimising the small, variable component while the large, constant one (health check detection) stays exactly where it was.
The practical order of operations is the opposite of the common advice:
Everything is public and runs on free-tier-sized instances: the Terraform code, the measurement scripts, and the raw CSVs from every run.
Total cloud spend for the entire experimental campaign was under €5. If you want to verify my numbers or run the tests against a different DNS provider, terraform apply gets you there.
The stateless app made RPO trivially zero, which avoids the hardest part of real disaster recovery: data. So this project is not finished. I'm already working on the next iteration, and the numbers will land here and in the repo.
Stay tuned and follow the repo for more to come.
2026-07-12 18:18:11
Originally published at ffmpeg-micro.com
Green screen removal is one of those FFmpeg operations that looks simple until you actually try it. Most tutorials are outdated, and the filter docs are dense. This guide covers everything you need to remove green screen backgrounds with FFmpeg's chromakey filter, from basic usage to production-ready API automation.
The chromakey filter compares each pixel in your video to a key color. Pixels close enough to the key color become transparent. Three parameters matter: color, similarity, and blend.
The filter operates in YUV color space, which makes it more forgiving with real-world footage where lighting isn't perfectly uniform. That's important. Studio green screens almost never have perfectly even lighting, and YUV comparison handles those gradients better than raw RGB matching.
The core command is short:
ffmpeg -i greenscreen.mp4 -vf "chromakey=0x00FF00:0.3:0.1" -c:v libvpx-vp9 -pix_fmt yuva420p output.webm
Breaking this down:
-i greenscreen.mp4 is your input file with the green screen footage.-vf "chromakey=0x00FF00:0.3:0.1" applies the chromakey filter. 0x00FF00 is the key color (standard chroma green), 0.3 is similarity, and 0.1 is blend.-c:v libvpx-vp9 encodes with VP9, which supports alpha channels.-pix_fmt yuva420p enables the alpha (transparency) channel. The "a" in "yuva" is what carries transparency.output.webm uses WebM format, which preserves transparency.One critical detail: your output format must support alpha channels. WebM (VP9) and MOV (ProRes 4444) both work. MP4 with H.264 does not support transparency at all. If you output to MP4, the transparent areas will render as black, and you'll wonder what went wrong.
These two parameters control the quality of your key, and getting them right is the difference between clean results and a mess.
Similarity (0.01 to 1.0) controls how close a pixel's color must be to the key color to be removed. A value of 0.01 means only exact matches get keyed out. A value of 0.3 is a solid default for most footage. Higher values like 0.5 are more aggressive and catch a wider range of greens.
Blend (0.0 to 1.0) controls edge smoothing. A value of 0.0 gives you hard, sharp edges. A value of 0.1 adds slight feathering. Higher values create softer transitions between the foreground and the transparent area.
You can compare different settings to find what works for your footage:
# Tight key: less spill, but may miss uneven lighting
ffmpeg -i greenscreen.mp4 -vf "chromakey=0x00FF00:0.15:0.05" -c:v libvpx-vp9 -pix_fmt yuva420p tight.webm
# Loose key: catches more green, but may eat into foreground
ffmpeg -i greenscreen.mp4 -vf "chromakey=0x00FF00:0.5:0.2" -c:v libvpx-vp9 -pix_fmt yuva420p loose.webm
Start with 0.3:0.1 and adjust from there. If you see green fringing around edges, bump similarity up slightly. If the key is eating into your subject's hair or clothing, bring it down.
FFmpeg has two color keying filters: chromakey and colorkey. Both remove pixels that match a target color, but they work differently.
chromakey operates in YUV color space. This makes it better for real-world green screen footage where the background has lighting variation, shadows, and wrinkles. It handles the natural inconsistency of physical green screens.
colorkey operates in RGB color space. It's better for exact, flat colors like solid graphic backgrounds or screen recordings with a uniform color. It's more precise but less forgiving.
For filmed green screen footage, chromakey is almost always the right choice. Use colorkey when you're working with digitally-generated content that has a perfectly uniform background color.
Once you've keyed out the green screen, you'll probably want to composite the footage over a new background. FFmpeg can do this in one pass using filter_complex and the overlay filter:
ffmpeg -i background.mp4 -i greenscreen.mp4 \
-filter_complex "[1:v]chromakey=0x00FF00:0.3:0.1[fg];[0:v][fg]overlay=0:0" \
-c:v libx264 -crf 23 -c:a copy output.mp4
This takes background.mp4 as the first input and greenscreen.mp4 as the second. The filter chain keys out the green from the second input, then overlays it on the first. Since the final output has a solid background, you can use MP4/H.264 here. Transparency is only needed in the intermediate step.
Note that if you're using the FFmpeg Micro API (covered below), -filter_complex isn't supported for security reasons. For compositing via the API, you'd split this into two calls: first remove the green screen and output as WebM with alpha, then overlay the keyed footage onto your background in a second job.
If you're automating green screen removal at scale, you probably don't want to manage FFmpeg installations on your servers. The FFmpeg Micro API lets you run the same chromakey operation via a REST call:
curl -X POST https://api.ffmpeg-micro.com/v1/transcodes \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inputs": [{"url": "https://example.com/greenscreen.mp4"}],
"outputFormat": "webm",
"options": [
{"option": "-vf", "argument": "chromakey=0x00FF00:0.3:0.1"},
{"option": "-c:v", "argument": "libvpx-vp9"},
{"option": "-pix_fmt", "argument": "yuva420p"}
]
}'
You pass in the source URL, set the output format to WebM, and provide the same filter options you'd use on the command line. The API handles the FFmpeg execution and returns the processed file.
Why does my chromakey output have no transparency?
You need a format that supports alpha channels. WebM with VP9 and MOV with ProRes 4444 both work. MP4 with H.264 can't carry transparency. Also make sure you're setting -pix_fmt yuva420p to enable the alpha channel in the encoding.
What color value should I use for green screen?
0x00FF00 is standard chroma green and works for most green screen setups. For blue screen, use 0x0000FF. If your screen isn't a standard color, take a screenshot of a frame and use a color picker tool to grab the exact hex value.
How do I handle uneven green screen lighting?
Increase the similarity value. Try 0.4 or 0.5 to catch a wider range of greens. This is more aggressive and may eat into foreground edges, so compensate by lowering the blend value to keep edges sharp.
Can I remove backgrounds that aren't green?
Yes. The chromakey filter works with any color. Change the color parameter to match whatever background you want to remove. Green and blue are convention, not a technical requirement.
If you don't want to manage FFmpeg locally, FFmpeg Micro lets you run these same operations via a simple REST API. Grab a free API key at ffmpeg-micro.com and try it.