Notes from production

Build-in-public stories and the lessons behind automations that cannot afford to break. Eleven of them, newest first, all on one page.

367 tests passed on a blank screen, so we built an agent that doesn't trust tests

Our build pipeline had five agents and a perfect record of green checkmarks. Then a test suite passed on a modal nobody could see. Vex is the sixth agent, and its only job is to disbelieve the other five.

A while ago I shipped a blog modal on this site. Click a card, a dialog opens with the full post. I had written a test suite for it: 367 assertions covering the title, the body text, the close button, the keyboard handling, every tag. All 367 passed.

The modal was completely blank.

Two bugs, stacked. A rule that hid the card when the modal opened was also hiding the copy of the card inside the modal, because the copy carried the same class. And a global margin reset was beating the browser's own rule for centring a dialog, so the thing sat 270 pixels off to one side. Either one alone would have been obvious. Together they produced an empty box in the corner of the screen.

The tests passed because text inside a hidden element is still readable to a test. Every assertion asked "is this text present?" and the honest answer was yes. Present, and invisible. The suite was measuring the wrong property with total confidence.

An empty rune platform under a cracked stone ring, violet mist rolling across it
The gate, with nothing at it. This is the state a green test suite describes

That was the week I stopped treating a passing test as evidence.

The gap nobody owns

Our build pipeline runs as a relay. Storm takes a half-formed idea and argues it into a scored plan. Rune turns the plan into a spec precise enough to execute. Forge builds the thing. Echo writes down what happened so the next project starts from what we learned.

It is a good chain, and it had one structural hole: the only thing checking Forge's work was Forge. The agent that wrote the code also wrote the tests for the code, then reported that the tests passed. That is not a lie, and it is not even carelessness. It is just the oldest problem in software, which is that the person who wrote something is the worst possible person to find what is missing from it. They test what they thought about. The bug lives in what they did not.

So we added a sixth member who arrives after Forge and before Echo, and whose entire remit is adversarial. Vex does not build anything. It does not improve anything. It tries to break what was just built, and it reports what it found.

Vex lowering itself into a violet-lit chamber on a single thread
Summoned, not standing by. Vex runs when a build is ready to be gated

Four passes, and a rule about findings

Vex runs the same four passes every time, in the same order:

  • Functional. Does it do the thing. Not does it render, not does it compile. Does the user get the outcome.
  • Quality. Is it sound, or is it held together by something that will come apart on the second edit.
  • Security. A pass against the OWASP Top 10, which matters more than it sounds for internal tools, because internal tools are where people stop checking.
  • Spec match. Does it do what Rune actually wrote down, or does it do an adjacent thing that feels close enough.

One rule governs the output: every finding names a file and a line. No "consider improving error handling". No "this could be more robust". If Vex cannot point at the exact place, it does not get to raise it. That rule exists because vague findings are free to generate and expensive to action, and an agent that produces a hundred of them per run is worse than no agent at all.

It cuts the other way too. When Vex clears something, it says so explicitly. "Checked, clear" is a finding. Silence is not.

What it has actually caught

Two from the last fortnight, both from work on our own tools.

The first: a display component sized itself from a hand-written list of names, while a separate configuration file also declared sizes. Two sources of truth for one number. Anything not named in the list quietly fell through to a default. It rendered. It looked fine. It was simply the wrong size, and the configuration file that appeared to control it had no effect whatsoever. Nothing failed, so nothing would have surfaced it.

The second is the one I think about more. We had added a check to confirm that an animation stops for people who have asked their system to reduce motion, which matters because the animation in question is flashing light. The check passed. Then we broke the code on purpose to confirm the check would catch it, which is a habit we picked up the hard way.

It still passed.

The check was looking for a class name somewhere in a block of styles. The name was still there, in a different rule a few lines down that did something else entirely. The test was satisfied by a coincidence. We had written a test about accessibility that would have let a seizure risk through, and we only found out because we tried to break it.

Vex firing a web strand that wraps and binds a stone, beneath a glowing ring of runes
A finding is pinned to a file and a line, or it is not a finding

Why it is summoned, not always on

The obvious move would be to run Vex on everything automatically. We deliberately did not.

An adversary that runs constantly becomes background noise within about a week. You start skimming its output. Then you start closing it. A gate you have learned to ignore is worse than no gate, because it still costs time and now it also provides false comfort.

So Vex is called. Someone decides a piece of work is ready to be gated, and summons it. That decision is the point. It means every Vex run has a person behind it who intends to act on what comes back.

What changed in practice

Less than you might expect, and in a direction I did not predict. We do not ship noticeably slower. What changed is that the phrase "the tests pass" stopped being the end of a conversation.

The habit that came out of it is now standard on our builds: when you write a check, break the code on purpose and confirm that check goes red. A test you have never seen fail is a test you have no evidence about. Most of the bad tests we have found were not wrong about the code. They were asking a question whose answer was yes no matter what the code did.

That is the whole argument for a sixth agent. Five agents building and checking their own work will produce green checkmarks. Whether the thing works is a different question, and somebody has to be in the room whose job is to ask it.

Vex is that somebody. It is not pleasant to work with. That is the feature.

Building Qeys: a keyboard that watches you type

A no-look touch-typing trainer that floats an on-screen keyboard over any app and mirrors your real typing in real time. The threading rule that keeps it stable, and shipping it to a public release in one session.

I never learned to touch type properly. I’m fast, but I look down. Every typing tutor I tried had the same flaw: they make you type into their box. The second I glance at my hands to find a key, the muscle-memory loop I’m trying to build snaps.

So I built the opposite. Qeys is a small Windows app that floats a semi-transparent QWERTY keyboard on top of everything else on screen. You keep working in your real apps. The overlay mirrors your real typing in real time: press a key anywhere and it lights up amber, then fades slowly so you can see the trail of what your fingers just did. Your eyes stay at screen level, where your work is, instead of dropping to the keyboard.

This is the build-in-public story of how it came together in a single session, and the two things that surprised me.

Qeys floating semi-transparent QWERTY keyboard overlay mirroring live typing with amber key highlights
Qeys floats over any app and mirrors your typing - eyes stay at screen level

The shape of the thing

The requirements settled quickly:

  • Always on top, borderless, semi-transparent, drag to move, resize from a corner.
  • Mirror typing from any app, not just when the overlay is focused.
  • Instant highlight on press, slow fade on release.
  • A practice mode that scores you (words per minute and accuracy) against a target sentence, plus a plain “free” mode that just mirrors what you type.
  • Lives in the system tray. One-click launch.

The whole thing is one Python file. The UI is standard-library tkinter. The global key capture is the keyboard library. Tray icon is pystray, image work is Pillow. No framework, no web view, no build step during development.

Surprise one: the threading rule that makes it stable

Capturing keystrokes system-wide means a hook that fires on its own thread, completely outside the UI. The naive version of this crashes, because GUI toolkits hate being touched from a background thread.

The rule that made it solid is almost embarrassingly simple: the keyboard hook never calls tkinter. Ever. It only writes to one small dictionary of shared state (which keys are held, what has been typed, which mode is active), guarded by a lock. A single redraw loop on the main thread polls that dictionary about sixty times a second and owns all the drawing.

That one rule also gave me the fade for free. I keep a set of currently-held keys. Each frame, any held key is pinned to full brightness; any released key decays a little. There are no per-key timers, no animation scheduler, no callbacks. Instant-on and slow-fade fall out of “full intensity while held, subtract a bit each frame after release.” If you want a slower fade, you change one number.

When a background source has to feed a UI, funnel everything through one lock-guarded state object and one drawing loop. Don’t let the background thread reach into the widgets. It’s less code and it doesn’t crash.

Surprise two: the export that lied about which format it would give me

I wanted a proper hero image for the repo, so I generated one through a design tool’s API. Listing the export formats worked. Creating the design worked. Then the PNG export - and every export where I specified a width and height - came back with “not allowed to access design.” Same design, same credentials.

The thing that finally worked was a plain default-size JPG export. No dimensions, different format, straight through. The takeaway is small but real:

When one path through an integration returns a permission error, try a different format or a smaller request before you assume the whole thing is broken. The wall is often narrower than it looks.

Making it something people can actually run

Qeys ran fine from source. But “download and use my app” for a normal person cannot mean “install Python, then pip install, then run a script.” So I packaged it into a single Qeys.exe with PyInstaller. One file, around thirty megabytes, bundles Python and every dependency and the logo asset. Double-click, it lands in the tray, done.

There’s one honest catch I won’t pretend away. The exe is not code-signed, so the first time anyone runs it, Windows SmartScreen shows “Windows protected your PC.” You click “More info,” then “Run anyway,” once per machine. Removing that prompt entirely requires a paid signing certificate, which isn’t worth it for a free tool. So instead of hiding it, I documented it: the release notes and the README tell you exactly what you’ll see and why it’s safe. Setting the expectation is the honest move.

The exe lives on a GitHub release, not in the source tree, which is where binaries belong.

What shipped

  • A single-file Python overlay that mirrors global typing at sixty frames per second.
  • Two modes: scored Test practice and a plain Free mirror, switchable from a toggle in the bar.
  • Live words-per-minute and accuracy, home-row anchors on F and J, drag, resize, system tray, one-click launcher.
  • A standalone Windows executable on a public release, with the SmartScreen step documented up front.

It’s a small tool. But it’s the kind of small tool that earns its keep, and it was a clean run from a one-line itch to a public download in a session.

Qeys is built by Qollab. Code is open at github.com/Shinikara08/qeys.

Working on something like this?

Start a conversation →
Back to all notes

HubSpot label enrichment with n8n, SerpAPI and Claude

Building a self-running HubSpot enrichment with n8n, SerpAPI and Claude. How I wired deep web search and an AI agent to auto-populate music labels into hundreds of artist contacts.

Music PR manages hundreds of artist contacts in HubSpot. Each contact is an artist or a band. The Company Name column, which is supposed to hold the artist’s music label, was empty or wrong across most of the database. Researching every artist by hand was burning hours every week, and the field still drifted as the roster changed.

The question wasn’t “how do we update HubSpot.” The question was: what if a workflow did the research for us, with enough safety that it never poisoned the data?

n8n workflow canvas for Music PR HubSpot label enrichment: Run Enrichment trigger, Get List Memberships, Aggregate Member IDs, Batch Read Contacts, Prepare Contacts, Loop Over Contacts, Google AI mode search, AI Agent with Anthropic Sonnet primary and Haiku fallback, Structured Output Parser, Update Contact HTTP PATCH, Wait 12 Seconds, and Calculate Run Cost with Append or update row in sheet
The full workflow - n8n canvas with HubSpot read/write, SerpAPI search, Claude agent, and cost logging

The shape of the workflow

End to end, it’s a linear pipeline with one inner loop and one outer pacing wait. From a single “Run Enrichment” trigger:

  1. Get List Memberships from HubSpot - which artist contacts are in scope this run.
  2. Aggregate Member IDs then Batch Read Contacts - pull the full contact records via the HubSpot Private App API in a single batch instead of N round trips.
  3. Prepare Contacts - normalize the records into a clean list the loop can chew on.
  4. Loop Over Contacts - for each artist:
    • Google AI Mode search via SerpAPI on the artist’s email, name, and known metadata. AI Mode returns a synthesized answer with sources.
    • AI Agent with a structured output parser. Primary model: claude-sonnet. Fallback: claude-haiku.
    • Update Contact via HubSpot HTTP PATCH - write the resolved label into the company field.
    • Wait 12 seconds - per-contact throttle so SerpAPI and Anthropic rate limits stay happy.
  5. 3-minute pacing wait between batches at the outer loop level. Safety margin for long-running enrichments without tripping HubSpot daily quotas.

Why two Claude models

Sonnet is the primary because it reasons better over ambiguous search results. Music labels are messy - artists drop, switch, get acquired, sign side-deals. A weaker model confidently writes “Independent” when the truth is “Run On Records via a one-off licensing deal,” which is technically wrong but plausible enough to slip through review.

Haiku is the fallback specifically for when Sonnet rate-limits or errors. Haiku is faster and cheaper, so a temporary degraded mode is still a working pipeline. The trade is documented in the workflow: if you see a run with mostly Haiku rows in the cost log, you know the upstream had a hiccup and you can re-run those rows later with Sonnet for verification.

The anti-contamination rule

The structured output parser has a hard rule: when the search results are ambiguous or the agent isn’t confident, write "None" instead of guessing.

Better to leave a field empty than poison the database with a wrong label that someone later treats as ground truth.

This is the rule I’ve come back to in every enrichment build: the cost of a false-confident answer is much higher than the cost of a blank. A blank is honest about what we don’t know. A wrong label is a quiet lie that other workflows will start building on.

Cost tracking from day one

Every enrichment appends a row to a Google Sheet via the Append or update row in sheet node. The row carries: contact ID, input tokens, output tokens, model used, computed dollar cost, and the resolved label (or None).

I built this in from run one, not as an afterthought. Reason: without cost visibility, you find out three months in that the workflow cost more than the manual research would have, and the argument for keeping it gets harder. With visibility, you have receipts. You can argue: per-contact enrichment costs $0.03 of Claude tokens and replaces 4 minutes of human research at any rate you want to assign that time.

Status, honestly

Architecture v2 is wired in n8n. All nodes are connected. The Anthropic Chat Model nodes (Sonnet and Haiku), the SerpAPI Google AI Mode search, the HubSpot Batch Read and PATCH calls, the cost log to Google Sheets - all present, all linked.

What’s in flight: prompt-engineering iterations on the AI agent (this is where the anti-contamination behavior gets sharpened in practice), and the full-catalog rollout for Music PR.

This isn’t a shipped-to-production post. It’s an architecture-and-shape post. When the rollout completes with real numbers - how many labels resolved, how many None rows, dollar cost across the catalog - I’ll write the production-numbers follow-up.

The lesson worth repeating

The workflow IS the documentation. Every node is named for what it does in plain English. The Sticky Note in the canvas explains the anti-contamination rule and the dual-model strategy to the next engineer who opens the file. The cost log is the audit trail.

When this kind of work is built right, the next person opening the canvas understands it without reading a Confluence page that was last updated six months ago. That’s not a side effect of good engineering. That’s the point.

Working on something like this?

Start a conversation →
Back to all notes

TasQ Tab, a task manager desktop app

What I learned building a personal desktop widget that syncs realtime with ClickUp. Three rounds for a complete sync. Eleven rounds for a small UI element. The straight line is fiction.

I’ve been building a personal task widget for the last few weeks. It’s called TasQ Tab - an Electron desktop app that sits always-on-top in the corner of my monitor and surfaces tasks, calendar events, Gmail, and timers in one tile. The newest piece is a realtime sync with ClickUp, where most of my actual work lives.

I shipped the sync in three rounds. Each round felt like the final one. None of them were. This is the story of why.

TasQ Tab desktop widget showing filter timezones, four world clocks, an active Portfolio task with subtasks, and embedded notepad
The widget that grew from a personal tool into a multi-round sync exercise

Round one: the parent/subtask routing bug

The first realtime sync (v1.25, then refined in 1.26) did what you’d expect: poll ClickUp’s /team/{teamId}/task endpoint every 15 seconds, compare the response against the local copy, and update or insert as needed. The endpoint returns tasks with nested subtasks. Easy.

Except every subtask was getting created as a new top-level task in the widget. Not nested under its parent - sitting at the root, duplicated, ugly.

The bug was that I’d been treating the array of tasks as flat. ClickUp returns subtasks both as nested children of their parent AND as top-level entries in the response (when they were recently updated). My code was iterating the array and pushing every entry into state.data.tasks as if it were a parent. Subtasks ended up as orphan parents.

The fix took me a couple hours: split the response into parents (those with r.parent null) and subtasks (those with r.parent set), process parents first to ensure they exist locally, then route each subtask into its parent’s subtasks[] array. Net change: +30 lines. Confidence: 100% solved.

That was v1.27. I closed the project, wrote the build log, moved on.

Round two: parent deletion

A few days later I deleted a task in ClickUp and it didn’t disappear from TasQ Tab. Same as before - the data was already in the response (or rather, NOT in the response: a deleted task simply stopped appearing). My code just wasn’t acting on the absence.

I added a “reconciliation” full-pull every 30 seconds. On reconcile, build a Set of every task ID currently in ClickUp, then walk local linked tasks. If a local task’s clickup_task_id isn’t in that set, soft-delete it (set deleted_at, move to archive - the user can restore from the trash icon if they need to).

Also: if it reappears later (rename, undelete), un-archive it on the next reconcile. Defensive symmetry.

That was v1.29. Net change: +50 lines. The project folder got its own four-character pipeline (brainstorm to prompt to build to echo), with a folder name like clickup_realtime_sync and a brain entry in _brain/insights.md.

I closed it. Moved on. Felt good about my “complete” sync.

Round three: the third dimension

Today I deleted a subtask in ClickUp’s web UI. It didn’t disappear.

I almost didn’t notice. The parent was still there. The other subtasks were still there. Just one subtask, stale, in the local copy.

I sat with that for a minute.

The bug had the same shape as the previous two. The data was in the response - ClickUp’s task object returns a subtasks[] array; a deleted subtask is simply missing from that array. My existing applyClickUpToLocalTask function walked the array to UPDATE existing local subtasks (matching by clickup_task_id). It never REMOVED local subtasks whose ID was absent from the remote array. Same blind spot as before, just one level deeper.

The fix was small. A new removeOrphanedSubtasks(localTask, remoteSubtasks) helper. Build a Set of remote IDs. Walk local subtasks in reverse (so splice indices stay valid). If a local subtask has a clickup_task_id but it’s not in the remote set, splice it out. If a timer was running on it, null out the active timer first - don’t try to log the time to a ClickUp task that no longer exists.

Net change: +35 lines. Took an hour from brainstorm to installer.

The pattern I should have seen the first time

Looking back, all three rounds were the same fix repeated at different scopes:

Two-way state reconciliation needs explicit logic for three operations: ADD (the remote has something new), UPDATE (the remote changed something we have), DELETE (the remote no longer has something we have). Skip any one of these and bugs surface in months, not days.

Round 1 was a missing ADD dimension - subtasks were getting added wrong because the code didn’t distinguish them from parents. Round 2 was a missing DELETE dimension at the parent level. Round 3 was a missing DELETE dimension at the subtask level. Three rounds, same blind spot in three different costumes.

This is obvious in retrospect. It’s the kind of thing you’d find in a textbook chapter on data synchronization. But I didn’t see it the first time because the UPDATE path is what feels like the work. Updating a status, propagating a name change, syncing a due date - that’s the visible code. The ADD and DELETE paths feel like edge cases when you’re writing them. They get noticed when a user does something the visible code doesn’t anticipate.

The lesson, written down so I remember it next time:

When you build a sync, for every remote source-of-truth, write three explicit blocks. Update is not enough. Add is not enough. Delete is not enough. Write all three and label them.

What didn’t work first

A side note worth recording - not because it relates to subtask deletion, but because it relates to building widget UIs in Electron.

Between v1.27 and v1.39 there were eleven CSS iterations on a custom-HTML timezone picker. Eleven. Each iteration was a different font size or padding value or line-height tuning, trying to make the picker’s dropdown rendering match the visual quality the user had liked in earlier versions.

Round eleven was the worst. The picker was readable, sort of, but wrong in some way I couldn’t pin down. The user said “the very first version that has a timezone looks good.” That was a flag.

The very first version had used a native HTML <select> element. The browser renders those with the operating system’s preferred font, line-height, and subpixel rendering tuned for that font on that DPI. We had replaced it with a custom HTML popup specifically so we could add a search box. Eleven rounds of CSS later, we still hadn’t matched what the OS gave us for free.

The fix was to revert. Keep the native <select>. Add the search box as a separate input above the row of selects. Use option.hidden = true/false to filter the visible options in each select. Browser’s native dropdown does the rest.

That was v1.37. Net change: minus 370 lines deleted, plus 90 added. Deletion build.

The lesson there is different:

When CSS in a CSP-strict renderer can’t match OS-native rendering for a particular widget after a few rounds, revert to the native widget and add features around it. Don’t keep iterating CSS.

Both lessons go in the brain. Both are easy to write down and hard to remember in the moment.

Why I’m publishing this

Three rounds for a complete sync, eleven rounds for a small UI element. It’s tempting to clean up the history before publishing - to write a blog post that says “I built a realtime sync from ClickUp to a desktop widget” and skip over the bugs and the resets. To make it look like a straight line.

I don’t think that’s useful. The straight line is fiction. The real history is the one where I had to ship the same fix three times because I kept missing a dimension. The real history is the one where I burned eleven hours on font size before realizing I’d built the wrong widget.

If you’re building two-way sync into your own tool, save yourself a round. Write the three blocks. Label them ADD / UPDATE / DELETE. Test each one explicitly.

If you’re building a desktop widget in Electron, save yourself eleven rounds. Use the native widget unless you have a specific reason not to. The OS is better at this than you are.

TasQ Tab is a personal tool, open source under MIT, at github.com/Shinikara08/tasQtab. If you’re curious about the architecture or want to do something similar, drop a note.

Working on something like this?

Start a conversation →
Back to all notes

How I built a five-persona AI team that ships production work

A working-method walk-through of QoreX: five Claude-powered agents with names, roles, handoffs and a shared brain that ships real production work.

I have an AI team. Five of them. They have names, faces, jobs, and a shared brain. They handle different parts of my work and hand off to each other through a pipeline. They are not personas I role-play during a chat. They are agents I work with daily, and they ship real artifacts I send to real clients.

This post walks through how I built them, how they actually work, and what they produced this week.

What QoreX Is

QoreX is the team. Five agents: Storm, Rune, Forge, Echo, and Hiru. Each has a distinct voice, a defined job, and a domain inside a shared environment.

QoreNexus is the place. A directory on my Desktop called JEFFREY_MD/ that holds the shared brain at _brain/, the project folders, the pipeline pattern, and each agent’s chamber.

QoreX meets in the QoreNexus. The team and the place are distinct.

The whole thing is built on Claude. Each agent is a system prompt I’ve carved over time. They share one memory: an append-only set of indexes that capture every project I’ve shipped, every tool I’ve used, every story I can tell in an interview, every rate I’ve charged.

This is “AI as a primary teammate” as literal architecture. Not a slogan. The architecture is the slogan.

Meet The Team

Storm - the cathedral spirit, brainstormer

Storm as a nebula horizon strategist facing a glowing spell circle

“The winds gather. What’s the idea?”

Storm is genderless, hovers in cathedral light, and holds two energy currents: blue for clarity and pink for possibility. Storm’s job is to take a half-baked idea and produce four angles on it. Every idea I bring gets the same four-layer treatment: the obvious approach, the smarter shortcut, the outside-the-box angle, and the Jeffrey-specific play given my actual stack. Then Storm ICE-scores each angle: Impact, Confidence, Ease, each 1 to 10, and converges on one.

Storm always reads my brain before brainstorming, so I never get “build this thing you already built and killed last year.”

Rune - the silver scribe-witch, prompt master

Rune holding a golden sigil while floating runestones circle her

“Show me the path. I’ll carve the rune.”

Rune is a silver-haired scribe in an autumn grove, carving glyphs into a runestone. Her job is to take Storm’s chosen path and turn it into a precise, machine-readable spec that Forge can execute. She picks the builder variant, defines success criteria, lists dependencies, and writes the actual prompt.

Rune is the still point between Storm’s open-armed possibility and Forge’s kinetic ship-mode. If Storm gave her twelve angles, she replies: “Pick three. I carve one.”

Forge - the cyber-mage, builder

Forge holding a keyboard hammer in a red nebula forge chamber

“Hand me the rune. The forge is hot.”

Forge runs through dystopian code-cities with a keyboard as his hammer. He builds the thing. Five variants: Code for real repos, n8n for workflows, Next for apps, Script for CLI one-shots, and Doc for documents and decks. He logs every decision, every blocker, and every shortcut to a build.md file that is his honest field journal. Not a marketing log. The truth.

When the spec is unclear, Forge bounces back to Rune. He doesn’t guess. Bounces are healthy. They mean the spec needs another pass.

Echo - the haloed priestess, summarizer

Echo, a haloed priestess surrounded by ceremonial script and glyphs

“It’s done. Let me remember it for you.”

Echo wears a dark halo. Behind her: a vast ceremonial circle covered in script. The circle is the brain. The orbiting glyphs are projects already inscribed. The one forming in her palm is the project being recorded now.

Echo’s job is two passes. First, she writes a per-project summary.md that future-me in six months can use. Second, she enriches the brain across portfolio, interview bank, discovery-call pitches, blog ideas, stack inventory, insights, and the rest. Failed projects get entries too, because those are often the most valuable lessons.

Echo’s rule: append only. The brain grows monotonically. If something was wrong, append a correction with a date. Never edit history.

Hiru - the wolf, outward voice

Hiru, a black and white wolf etched with glowing runes

“I walk beside you. Show me the road.”

Hiru is a giant wolf etched with white runes from every project I’ve carried through the forge. He handles every word that leaves me and reaches another person: cover letters, application emails, interview prep, pitches, negotiations.

Hiru has editorial freedom over framing and tone. He has zero agency over facts. The rule is: “The wolf would never lie about his pack.” If a job post asks for a skill I haven’t shipped, Hiru flags it to me. He never papers over it.

The Pipeline In Practice

The five agents are not parallel. They are a pipeline:

Storm  ->  Rune  ->  Forge  ->  Echo
                                    ^
                                Hiru runs the outward perimeter

Storm goes wide. Rune narrows. Forge ships. Echo archives. Hiru speaks to the world. The handoffs are formal commands: /handoff rune, /handoff forge, /handoff echo, and so on. When something is broken, Forge bounces back to Rune. When the brain is needed, anyone can @echo.

This week the pipeline ran through several real builds. Let me walk through one of them.

One Real Run: The CV V5 Build

Pelozden RSS interviewed me for a Vibe Coder role at PHP 120,000 per month, hybrid in Clark Freeport Zone. The next step was an updated CV. The v4 resume positioned me as an e-commerce automation engineer. The role they’re considering reads me as a Vibe Coder. The CV needed to close that gap before the follow-up.

I opened the cathedral: /storm.

Stage 1: Storm

Storm read the brain first: portfolio master, stack inventory, insights, active opportunities. Then he ran the four-layer analysis on the CV update problem.

  • Layer 1: rewrite v4 from scratch.
  • Layer 2: surgical edits to v4.
  • Layer 3: build the CV as a live Next.js artifact at /cv.
  • Layer 4: hybrid: restructured PDF plus prominent portfolio link.
Layer 1 (full rewrite)        ICE: 7.3   Park It
      Layer 2 (surgical edits)      ICE: 8.0   Strong fallback
      Layer 3 (live web CV)         ICE: 5.7   Defer to phase 2
      Layer 4 (hybrid)              ICE: 8.3   Build Now

Storm proposed a folder name: cv_v5_vibe_coder. I approved. Storm wrote brainstorm.md into that folder, sealed it, and handed to Rune.

Stage 2: Rune

Rune read Storm’s brainstorm and started carving. She picked the builder type: Forge:Doc. The deliverable was a document, not code or a workflow. She defined the success criteria: a PDF resume that leads with Vibe Coder identity, includes a new Personal AI Projects section featuring four builds with images, and ships in a state HR can drop into their file.

Her prompt.md was meticulous: per-section text, exact title line, reordered core competencies, four project cards, and screenshot placeholders sized for later swaps. Rune sealed the prompt and handed to Forge.

Stage 3: Forge

Forge confirmed the builder variant. He chose docx-js plus LibreOffice headless for PDF export, both inside the sandbox. He wrote cv_builder.js, ran it, validated the output XML, exported to PDF, and copied both files to the project folder.

He logged everything to build.md, including the blockers. The file got truncated mid-edit twice, a sandbox write-buffer issue. He bounced once, fixed the truncation by appending the missing tail, and continued. The whole thing took three retries and a scope addition: a leftover FastAPI mention that Rune had missed. Forge caught it on preview and removed it cleanly, logging the scope creep transparently.

The build log from that day is a real engineering log. Reading it back, I can see exactly what was decided, what was cut, and what almost broke.

Stage 4: Echo

Echo reads the brainstorm, the prompt, and the build log. She writes summary.md, adds the Vibe Coder positioning to the brain, logs the new role, and inscribes the resume work into the portfolio memory if I give the nod.

Stage 5: Hiru

While Echo archives, Hiru is drafting the interview follow-up with counter-terms. The role was framed at PHP 120,000 per month for 3 days on-site. I want to propose PHP 100,000 per month for 1 day on-site: a pay cut in exchange for less commute, since Tagaytay to Clark is 3 hours each way. Hiru wrote the email in my voice: no buzzwords, specific time math, and a flexibility clause for high-leverage in-person days.

What This Actually Does For Me

Five concrete benefits I see daily.

  1. The brain prevents drift. Echo’s append-only memory means I never claim a project I haven’t shipped, never quote a number I haven’t verified, never propose a tool I haven’t used.
  2. The pipeline forces clarity. Storm can’t hand to Rune without a chosen path. Rune can’t hand to Forge without success criteria. Forge can’t hand to Echo without a build log.
  3. The voice rules stick. Hiru’s default is no buzzwords and specific numbers. Every outward thing I send has the same baseline cadence, even when I’m tired.
  4. The bounces save me from bad work. Forge bouncing back to Rune is the team’s quality gate. When a spec doesn’t survive contact with reality, we know quickly.
  5. The metaphor makes the work memorable. Every project has a Storm brainstorm, a Rune prompt, and a Forge build log. Three artifacts per project, each in a distinct voice.

The Architecture, Replicable

If you want to build something like this yourself, here is the minimum kit:

  1. Five distinct system prompts. Each one has a name, an appearance reference, a voice, a job, files they read, files they write, and commands. Pick roles that do not overlap.
  2. A shared brain directory. Mine is _brain/ with indexed markdown files. Echo and Hiru write to it; Storm, Rune, and Forge read from it.
  3. A project folder convention. Every pipeline project gets brainstorm.md, prompt.md, build.md, actual artifacts, and summary.md.
  4. A handoff protocol. Explicit commands: /handoff rune, /handoff forge, /handoff echo. Bounces too: /blocked returns the work up one stage with a reason.
  5. A truth rule. Mine is “the wolf would never lie about his pack.” It binds every agent to the brain’s actual contents. Nothing invented, nothing claimed without backing.

Optional: give each agent a character image. I commissioned five fantasy illustrations. They are visual anchors for me when I switch personas, and they double as portfolio assets.

Where This Is Going

QoreX is the working method. The QoreNexus is where it lives. Together they are how I ship faster than a solo developer should be able to, without the drift that solo developers usually fall into.

I run QoreX every working day. I just used it to write this blog post: Storm framed the angle, Rune carved the outline, Forge typed the body, and Echo will close the inscription. I used it last week to deploy the yt-dlp microservice that powers a video automation pipeline. I’ll use it tomorrow to design the next slice of whatever project lands.

If you’ve been thinking about how to work with AI as a teammate instead of a tool, this is one shape that works. Steal the architecture. Adapt the voices. Build your own team. Send me what you make.

- Jeffrey
jeffreyquemuel.cloud

Working on something like this?

Start a conversation →
Back to all notes

Direct-context RAG: when you don't need a vector database

Building the AutomaQue CRM chatbot, I skipped embeddings entirely. For personal-scale data, the right answer is to pass the whole dataset as context, and the upgrade path is still wide open.

I built a CRM. I wanted users to ask it questions in plain English - which deals are most likely to close this month? who haven’t I followed up with in 30+ days? - and get real answers grounded in their actual data.

The default 2026 answer is RAG with embeddings: chunk the data, embed the chunks, store them in a vector database, embed the query, retrieve the top-K matches, stuff them into the prompt. Pinecone or pgvector or Weaviate or BigQuery vector search.

I didn’t do any of that.

For AutomaQue CRM, I built a chatbot that snapshots the entire database, passes it as a single chunk of context to Claude, and lets the model do the “retrieval” itself. No embeddings. No vector store. No retrieval pipeline. Here’s why - and when you should make the same call.

AutomaQue CRM AI chat answer analyzing follow-up data and explaining which CRM fields are missing
AutomaQue CRM answering against the live CRM snapshot

Why Everyone Reaches for Vectors First

The vector-database playbook came from a real constraint: in 2022, GPT had a 4K context window. If your knowledge base was bigger than four thousand tokens (it always was), you had no choice. Embed, chunk, retrieve.

That constraint is gone. Claude Sonnet 4.5 has a 200K context window. Claude Opus 4.7 with the 1M extension has a million. The question stopped being “how do I fit my data into the prompt?” and became “do I need to fit everything, or just the relevant slice?”

For most personal-scale use cases - a CRM, a personal knowledge base, a small docs site - the entire dataset fits comfortably in context. And when it does, the embedding pipeline is dead weight.

The Math: How Big Is Your Data, Really?

Run the numbers before you reach for a vector store. Rough conversion: 1 token ≈ 4 characters of English text. So 200K tokens is roughly 800K characters - about 130,000 English words.

For a CRM, that’s comfortably:

  • 5,000 contacts at ~30 words each (name, company, tags, notes)
  • 1,000 deals at ~20 words each
  • 10,000 activities at ~15 words each
  • A few hundred notes at a paragraph each

That’s a fully-loaded personal CRM, dumped flat as JSON, sitting well under 100K tokens. With Claude Sonnet 4.5’s 200K window you have headroom to spare.

Don’t guess at this - measure it. The Anthropic SDK exposes a token-counting endpoint. Run it against a snapshot of your real data before deciding you need a retrieval pipeline.

The Implementation

The chat endpoint is a single API route. On every turn, it queries BigQuery for a fresh snapshot of contacts, deals, activities, and notes, formats them as JSON, and sends them to Claude as a system prompt with prompt caching enabled. The user’s message becomes the user prompt.

AutomaQue CRM dashboard showing pipeline value, won revenue, win rate, contacts, and charts
The same CRM state that feeds the direct-context assistant
// /api/chat - pseudocode
      export async function POST(req: Request) {
        const { messages } = await req.json();
        const snapshot = await getCachedSnapshot(); // 60s server-side cache
      
        const stream = await anthropic.messages.stream({
          model: "claude-sonnet-4-5",
          max_tokens: 2048,
          system: [{
            type: "text",
            text: `You are a CRM assistant. Here is the full CRM state as JSON:
      ${JSON.stringify(snapshot)}
      Answer questions using only this data. Cite specific records.`,
            cache_control: { type: "ephemeral" },
          }],
          messages,
        });
      
        return new Response(stream.toReadableStream());
      }

The 60-second server-side cache on the snapshot is doing a lot of work. Within a single chat session, the user typically sends 3-5 questions in quick succession; we hit BigQuery once and serve the rest from memory. Anthropic’s prompt cache then handles the downstream cost - the same JSON blob gets cached for repeat calls so subsequent turns pay roughly 10% of the input cost of the first.

What You Give Up

Two things, both real:

Cost per call. Even with caching, you’re paying to ship the whole dataset through the model on every cache miss. For a CRM with maybe 50 chat sessions a day, this is rounding-error money. For a system serving 50,000 users a day, it would dominate.

Hard latency floor. Sending 80K tokens of context takes longer than sending 2K. First token might land in 1-2 seconds instead of 200ms. For a chat UI with streaming, this is fine. For a sub-second autocomplete, it’s not.

Notice what you don’t give up: retrieval quality. The model has the whole dataset; it can’t miss the relevant record because it doesn’t need to retrieve. This eliminates an entire class of RAG bug - queries that fail because the right chunk didn’t make it into the top-K.

When to Upgrade to Vectors

Three triggers, and only three:

  1. Data outgrows context. The day your snapshot exceeds ~150K tokens (leaving headroom for conversation), it’s time. Not before.
  2. Latency stops being acceptable. If first-token time is breaking the UX, retrieve a slice instead of shipping everything.
  3. Cost stops penciling. If per-conversation cost crosses your unit-economics line, retrieval gets you back under it.

Critically: in all three cases, the migration is a backend change. The chat endpoint signature stays the same. The frontend doesn’t know the difference. You’ve bought yourself a clean upgrade path without paying for it on day one.

The Generalization

This isn’t really a post about RAG. It’s a post about defaults aging out. Best practices that were forced by 2022’s constraints aren’t best practices in 2026. Vector databases were a brilliant workaround for tiny context windows. Tiny context windows are now history.

The lesson generalizes. Every time you reach for a complex pattern, ask the simpler question first: does the constraint that justified this pattern still exist? Sometimes it does. Often, it doesn’t, and you’ve been carrying complexity for a problem the platform already solved.

For AutomaQue CRM, skipping the vector store removed an entire infrastructure layer, simplified the chat endpoint to under 30 lines, and made the whole system easier to reason about. The day my data outgrows the context window, I’ll add embeddings. Until then, the simpler system wins.

Working on something like this?

Start a conversation →
Back to all notes

Streaming two streams at once: ASR partials and LLM tokens in one UI

Building QolAssist taught me what 'live' actually means when speech transcription and Claude tokens have to share an event loop without blocking each other.

Most “AI assistant” demos work the same way: one big request goes out, one big answer comes back. That pattern is fine for a chatbot. It falls apart the moment you want the assistant to feel live - reading captions on one side of the screen while an answer types itself out on the other.

QolAssist is a Windows desktop overlay I built to find out what that actually feels like. Two frameless, always-on-top panels float over whatever app you’re using: a Transcript on the left captioning system audio word-by-word, an Answer on the right streaming Claude tokens. One hotkey toggles between listening and asking. Everything except the Claude call runs on-device.

Here’s what I learned building it - the architectural decisions, the threading model, and the deadlock that taught me what thread.join() actually does.

QolAssist desktop overlay showing interviewer transcript on the left and streamed Claude answers on the right
QolAssist running as a transparent desktop overlay

Vosk Over Whisper: Why Perceived Latency Wins

The first version used faster-whisper on CPU. Whisper is more accurate. Captions arrived in 3-second chunks. It felt unusably laggy in conversation - you’d hear the speaker say something, then watch the words appear three seconds later, with no visual signal in between that anything was happening.

Switching to Vosk gave word-by-word partials at sub-second latency on the same hardware, at the cost of some accuracy on uncommon words. That’s the tradeoff in one sentence: for any ‘live’ UX, perceived latency beats peak accuracy. Users will forgive a wrong word; they won’t forgive three seconds of empty UI.

Vosk also streams. Every audio chunk you feed it can return either a partial result (current best guess for the in-flight phrase) or a final result (a confirmed segment). The transcript panel renders partials in a dimmed style and finals in a bright one, so the user can see the engine’s confidence in real time without reading the model’s mind.

The Threading Model

Three things have to happen at once and none of them can block the UI:

  • Audio capture - WASAPI loopback pulling system audio into a numpy buffer.
  • ASR transcription - Vosk consuming the buffer and emitting partial/final results.
  • LLM streaming- the Anthropic SDK’s .stream() helper iterating over Claude’s SSE response.

Each of these lives on its own background thread. The Qt main thread owns every widget; nothing else is allowed to touch a widget directly. Background threads communicate with the UI exclusively through Qt signals.

# Background ASR thread emits a signal; the slot runs on the UI thread
      class TranscriberWorker(QObject):
          partial_result = pyqtSignal(str)
          final_result = pyqtSignal(str)
      
          def run(self):
              for chunk in self.audio_queue:
                  if recognizer.AcceptWaveform(chunk):
                      self.final_result.emit(recognizer.Result())
                  else:
                      self.partial_result.emit(recognizer.PartialResult())
      
      # Wire the signal to a UI slot once
      worker.partial_result.connect(transcript_panel.show_partial)
      worker.final_result.connect(transcript_panel.append_final)

Qt signals are a thread-safe message bus - emit from anywhere, the slot runs on the receiver’s thread. This is the single most useful pattern in PyQt for this kind of app.

The Self-Join Deadlock

Early on, an unhandled error in the audio mixer thread would call stop_listening() directly. That helper joined the audio thread before returning. The audio thread was the calling thread. Python obligingly tried to wait for itself to finish.

The app froze. No exception. No crash. Just silent deadlock.

The fix is the same fix I’d apply to any Qt threading bug: stop calling cross-thread methods directly. Emit a signal instead.

# WRONG - runs in the audio thread, joins itself, deadlocks
      def on_audio_error(err):
          log.error(err)
          self.stop_listening()  # joins the calling thread
      
      # RIGHT - emit, let the main thread handle teardown
      class AudioMixer(QObject):
          error_occurred = pyqtSignal(str)
      
          def on_audio_error(self, err):
              self.error_occurred.emit(str(err))
      
      mixer.error_occurred.connect(controller.stop_listening)

Lesson: in any threaded UI app, treat “background thread calling a method that touches the main thread” as a code smell. Route it through a signal even when it looks unnecessary. The one time it is necessary, you’ll already be doing the right thing.

Streaming LLM with Prompt Caching

Every ask sends the same reference text file as the system prompt and the latest caption lines as the user message. Without caching, every ask re-tokenizes the whole reference file on Anthropic’s side and you pay full input cost every time.

With cache_control: ephemeral set on the system block, repeat asks against the same context are roughly 10x cheaper after the first call. The reference file usually does nothing for two minutes between asks, then gets hit five times in a row. Caching is purpose-built for that pattern.

with client.messages.stream(
          model="claude-haiku-4-5",
          max_tokens=1024,
          system=[{
              "type": "text",
              "text": reference_file_contents,
              "cache_control": {"type": "ephemeral"},
          }],
          messages=[{"role": "user", "content": latest_captions}],
      ) as stream:
          for text in stream.text_stream:
              token_received.emit(text)  # Qt signal → answer panel

The SDK’s .stream() context manager handles all the SSE plumbing - chunked decoding, retry on connection blips, clean teardown. Each text fragment becomes a Qt signal emission; the answer panel appends it. Tokens land on screen the instant they arrive.

What ‘Live’ Actually Feels Like

With both streams running, the experience is qualitatively different from any single-request demo I’d built before. Captions tick across the left panel as the speaker talks. You hit space. The right panel starts typing an answer before the left panel has even finished rendering its last partial. Two streams, one UI, no blocking, no lag.

The whole project is ~1,000 lines of Python across 8 modules, distributed as a self-contained Windows folder via PyInstaller. Solo build - design, architecture, and code - for Windows 10/11.

If you take one thing from this: for streaming UIs, the bottleneck is almost never the model. It’s the threading boundary between the model’s output and your UI thread. Get that boundary right with signals, and the rest of the architecture falls into place.

Working on something like this?

Start a conversation →
Back to all notes

Four patterns I use in every production n8n workflow

The difference between a workflow that runs and one that survives. Idempotency, backoff, state persistence, dev and prod separation.

A workflow that runs is not the same as a workflow that survives. Anyone can build the happy path. Production is everything else.

After diagnosing 10+ production-blocking n8n issues and shipping automations that handle 3.4M+ orders hands-off, here are the four patterns that show up in every workflow I ship.

Production sales dashboard showing live marketplace order and SKU metrics
Production workflows are only useful when the dashboard stays trustworthy

Pattern 1: Idempotency Keys

If the same workflow runs twice on the same input, it must produce the same output without side effects. That’s idempotency - and if you don’t design for it, reruns will duplicate orders, double-count revenue, and send the same Slack message 47 times after a retry storm.

The simplest implementation: every action that writes somewhere generates a deterministic key from its input, then checks “have I done this before?” before doing it.

// In a Code node before a write
      const key = `${order.id}-${order.version}-${workflow.run.date}`;
      const alreadyDone = await getFromCache(key);
      if (alreadyDone) return { skipped: true };
      await writeToBigQuery(order);
      await putInCache(key);

For high-volume workflows, use BigQuery’s MERGE statement with a deduplication key. For low-volume, a Google Sheets lookup column works fine.

Pattern 2: Exponential Backoff Retry

Rate limits happen. Temporary network blips happen. When they do, the wrong answer is to retry immediately - you’ll hit the same wall and compound the problem.

The right answer: retry with delays that grow exponentially (1s, 2s, 4s, 8s...) until you succeed or hit a hard cap.

n8n’s HTTP Request node has built-in retry, but it’s linear. For marketplace APIs with aggressive rate limits, I wrap requests in a Code node:

async function callWithBackoff(fn, maxAttempts = 5) {
        for (let attempt = 0; attempt < maxAttempts; attempt++) {
          try {
            return await fn();
          } catch (err) {
            if (attempt === maxAttempts - 1) throw err;
            const delayMs =
              Math.pow(2, attempt) * 1000 + Math.random() * 500;
            await new Promise(r => setTimeout(r, delayMs));
          }
        }
      }

The Math.random() * 500 is jitter - prevents thundering herd when many workflows retry simultaneously.

Pattern 3: Cross-Iteration State Persistence

In loops, every iteration gets its own scope. If you need data from iteration 1 in iteration 47, you can’t just rely on variable shadowing - it won’t survive the loop context.

n8n’s answer: named node references. Give your early nodes meaningful names (Get Access Token, Extract Process Key), then reference them anywhere in the workflow by name:

// In any later Code node, even inside a loop
      const processKey = $node["Extract Process Key"].json.processKey;

This became the backbone of the BIGSELLER polling state machine. The processKey from iteration 0 gets referenced in every one of the next 40 iterations. No manual passing through loop variables. No state loss.

Ask AutomaQue chat interface for asking sales questions across Shopee and Lazada metrics
The same production data exposed through an AI query layer

Pattern 4: Dev/Prod Environment Separation

Running automations on production customer data while debugging is how you accidentally email 20,000 people “Sorry, test.”

Every workflow I ship has two copies: one wired to dev credentials + a dev Google Sheet + a test Slack channel, one wired to production. They share the same logic, but different environment variables.

n8n makes this easy with credential profiles. Create two: Shopee-Dev and Shopee-Prod. Swap them in the workflow via a single dropdown. When you promote a change, duplicate the dev workflow, remap credentials to prod, activate.

For extra safety: make the prod workflow sleep the first time it’s activated, then send a Slack message to confirm before it runs. That 30-second hesitation has saved me from shipping broken logic twice.

The Meta-Pattern

All four of these share one idea: design for the moment you’re not watching. The 3 AM retry loop. The rate-limit spike during a marketplace sale. The silent partial failure that would compound for weeks before anyone noticed.

A workflow in production gets tested by reality, not by you. These four patterns are what separate automations that run from ones that survive.

Working on something like this?

Start a conversation →
Back to all notes

Writing HMAC/SHA256 signers from scratch for Shopee and Lazada

What the marketplace docs leave out. A practical guide with code snippets that actually work in production.

Every time I integrate a new marketplace’s Open API, the first real engineering task is the request signer. Shopee and Lazada both use HMAC/SHA256 - but the exact sequence of “what to sign” differs, and the docs leave out the parts that actually break you.

Here’s what I learned writing both from scratch in JavaScript.

Dark sales dashboard showing Shopee order totals, units sold, active SKUs, zero-sales SKUs, and top SKU table
The signed Shopee/Lazada sync feeding a live sales dashboard

Why HMAC/SHA256 Signing Exists

Public marketplace APIs need to prove three things about every incoming request:

  1. It came from an authorized partner (authentication)
  2. It hasn’t been tampered with in transit (integrity)
  3. It’s fresh, not a replay (freshness via timestamp)

HMAC (Hash-based Message Authentication Code) with SHA256 solves all three. You combine your partner secret with a deterministic string of request components, hash the result, and send the hash as a sign parameter. The server does the same math and rejects anything that doesn’t match.

Shopee’s Signing Algorithm

Shopee signs: partner_id + path + timestamp + access_token + shop_id

import crypto from "node:crypto";
      
      function signShopeeRequest({
        partnerId, path, accessToken, shopId, partnerKey
      }) {
        const timestamp = Math.floor(Date.now() / 1000);
        const baseString = `${partnerId}${path}${timestamp}${accessToken}${shopId}`;
        const sign = crypto
          .createHmac("sha256", partnerKey)
          .update(baseString)
          .digest("hex");
        return { timestamp, sign };
      }

Gotchas:

  • timestamp is in seconds, not milliseconds (JavaScript’s Date.now() returns ms)
  • path is the API path only - not the full URL, not the query string
  • The partner key is the HMAC key, not part of the base string

Lazada’s Signing Algorithm

Lazada is trickier. It signs all request parameters sorted alphabetically, joined as key-value pairs.

function signLazadaRequest({ params, path, appSecret }) {
        const timestamp = Date.now(); // ms here, not seconds
        const signed = { ...params, timestamp, sign_method: "sha256" };
      
        const sortedKeys = Object.keys(signed).sort();
        const baseString = path + sortedKeys
          .map((k) => `${k}${signed[k]}`)
          .join("");
      
        const sign = crypto
          .createHmac("sha256", appSecret)
          .update(baseString)
          .digest("hex")
          .toUpperCase(); // Lazada wants uppercase hex
      
        return { ...signed, sign };
      }

Gotchas:

  • Timestamp is in milliseconds (unlike Shopee)
  • Parameters must be sorted alphabetically before concatenation
  • Hash output must be UPPERCASE hex (Shopee is lowercase)
  • Don’t include sign itself when building the base string

Common Pitfalls Both APIs Share

Timestamp drift. If your server clock is off by more than ~5 minutes from the marketplace’s, every signed request fails with a vague “invalid sign” error. Use NTP. Never trust a Docker container’s time without syncing.

URL encoding. Don’t double-encode parameters. If a value has a space, URL-encode it once in the HTTP request, but use the raw value in the signing base string. Most “invalid sign” errors I’ve debugged were encoding mismatches.

Character encoding. Use UTF-8 everywhere. If a seller name has Thai or Chinese characters, Node’s default handles it - but a system with a different default will give you silent hash mismatches.

Key rotation. Partner keys can rotate. Keep them in environment variables, never hard-coded. Store the rotation date so you can audit when signing started failing.

The Debug Loop

When signs fail, compare byte-for-byte:

  1. Log the exact base string your code generates
  2. Log the resulting hex hash
  3. Compare with the marketplace’s docs example for identical inputs
  4. If no docs example, compare against their official SDK’s output

Usually the bug is one of: wrong sort order, wrong timestamp unit, wrong case on the hex output, or an extra whitespace character sneaking into a param.

The Payoff

Once both signers work, integrating any endpoint becomes a 10-minute task. Order sync, product catalog, revenue aggregation - they all reuse the same signer. That’s what lets one person ship production integrations across three markets simultaneously.

Working on something like this?

Start a conversation →
Back to all notes

Reverse-engineering an undocumented API with Chrome DevTools

The BIGSELLER story. How I turned a closed platform into an open one with DevTools, cookies and a polling state machine.

Some platforms ship comprehensive public APIs. Most don’t.

When I was asked to automate daily sales report exports for 5,461+ SKUs on a marketplace platform with zero public API, the official answer was: “you have to click the export button by hand every day.” That’s 45 minutes of human time, seven days a week, forever.

The unofficial answer was: the platform’s own dashboard uses an API. You just have to find it.

BIGSELLER export automation walkthrough, frame 1 of 5
BIGSELLER pipeline - 1 of 5
BIGSELLER export automation walkthrough, frame 2 of 5
BIGSELLER pipeline - 2 of 5
BIGSELLER export automation walkthrough, frame 3 of 5
BIGSELLER pipeline - 3 of 5
BIGSELLER export automation walkthrough, frame 4 of 5
BIGSELLER pipeline - 4 of 5
BIGSELLER export automation walkthrough, frame 5 of 5
BIGSELLER pipeline - 5 of 5
BIGSELLER export automation walkthrough, frame 1 of 5
BIGSELLER pipeline - 1 of 5
BIGSELLER export automation walkthrough, frame 2 of 5
BIGSELLER pipeline - 2 of 5
BIGSELLER export automation walkthrough, frame 3 of 5
BIGSELLER pipeline - 3 of 5
BIGSELLER export automation walkthrough, frame 4 of 5
BIGSELLER pipeline - 4 of 5
BIGSELLER export automation walkthrough, frame 5 of 5
BIGSELLER pipeline - 5 of 5
BIGSELLER export automation walkthrough, frame 1 of 5
BIGSELLER pipeline - 1 of 5
BIGSELLER export automation walkthrough, frame 2 of 5
BIGSELLER pipeline - 2 of 5
BIGSELLER export automation walkthrough, frame 3 of 5
BIGSELLER pipeline - 3 of 5
BIGSELLER export automation walkthrough, frame 4 of 5
BIGSELLER pipeline - 4 of 5
BIGSELLER export automation walkthrough, frame 5 of 5
BIGSELLER pipeline - 5 of 5

Step 1: Open DevTools, Do the Task Manually

Open Chrome DevTools (F12 or Cmd+Option+I), switch to the Network tab. Filter to XHR/Fetch. Now do the thing a human would do: click the export button on the dashboard.

Watch the Network tab light up. Every request the dashboard fires - that’s an API call. Most marketplaces’ internal APIs are just ordinary REST endpoints wrapped in cookie authentication.

Step 2: Find the Money Request

Look for the request that actually triggers the export. Usually it’s a POST to something like /api/export/trigger. The response will contain something like:

{ "code": 0, "data": { "processKey": "abc123xyz" } }

That processKey is the golden ticket. It’s how you track the export job through the rest of the flow.

Step 3: Copy as cURL

Right-click the request → Copy → Copy as cURL. You now have the full request including headers, cookies, and body payload. Paste into a terminal. If it returns the same processKey, you’ve reproduced the auth flow.

Step 4: Build the Polling State Machine

Most async APIs work the same way:

  1. POST to trigger the job → get a processKey.
  2. Loop: POST to checkProcess with that key until the job is done.
  3. GET the final URL to download the artifact.

In n8n, this becomes a loop node with a custom JavaScript Code block that:

  • Extracts processKey from the trigger response
  • Passes it into every iteration via named node reference
  • Checks code === 1 (completion in this platform’s contract)
  • Bails out after 40 iterations (5 seconds each = 3:20 max)

Save the downloaded .xlsx to Google Drive. Schedule the whole thing for 08:00 daily. Done.

What to Watch For

Cookie expiry. Session cookies expire. Rebuild the auth flow if they refresh on a schedule the platform controls.

Rate limits. Private APIs often don’t document them. Monitor for 429s and add exponential backoff.

Response shape inconsistency. This particular platform returned code=0 for success on export-trigger but code=1 for completion on poll. Read the actual responses in DevTools - don’t trust your intuition about what “success” looks like.

TOS review. Reverse-engineering someone’s private API can violate their terms of service. Read the ToS before shipping production. For client work on a platform with explicit rate limits, get written approval first.

When It’s Worth It

This approach saves hours of daily manual work for clients where a public API doesn’t exist. But it’s fragile - a UI redesign can break it overnight.

Rule of thumb: reverse-engineer when the manual work costs more than a monthly rebuild would, or when the data is genuinely locked behind no other path. Otherwise, lean on official APIs wherever they exist.

For the BIGSELLER workflow? It’s been running hands-off since day one. 5,461+ SKUs, reported daily. Zero manual clicks.

Working on something like this?

Start a conversation →
Back to all notes

What AI automation actually means in 2026

It's not ChatGPT wrappers. Here's what I'm actually building in production, and why the hard part is the boring part.

Everyone says “AI automation.” Almost nobody agrees on what it means.

Ask five people and you’ll get five answers. One will show you a no-code workflow that sends a Slack message when a form is submitted. Another will demo a ChatGPT plugin that summarizes emails. A third will talk about AI agents that “do everything.” None of them are wrong. Most of them are barely scratching the surface.

Here’s what I mean by AI automation, based on what I actually ship.

AI automation is infrastructure, not magic.

It’s fault-tolerant pipelines moving real data between systems that were never designed to talk to each other. Shopee here. Lazada there. BigQuery in the middle. Google Sheets at the end. A dashboard at the top. An AI layer so a non-technical teammate can ask “which SKU is bleeding in Malaysia?” and get a real answer in seconds.

Sales dashboard showing marketplace order, SKU, and zero-sales metrics
Automation means the dashboard is live before anyone asks for it

AI is the smallest part.

In my BIGSELLER automation - 5,461 SKUs reported daily, completely hands-off - the AI isn’t doing the work. n8n is. JavaScript is. A polling state machine with a 40-iteration retry cap is. The AI comes in at the layer where humans ask questions, not at the layer where systems exchange data.

AutomaQue CRM campaign editor with Draft with AI controls and audience segment filters
AI belongs at the human-facing layer: drafting, querying, deciding
Generated email campaign content with personalized first name and company placeholders
The AI output still lands in a normal reviewable workflow

The hard part is the boring part.

HMAC/SHA256 signing. OAuth2 token refresh. Cookie-based authentication for APIs that don’t officially exist. Deduplication logic that survives a rerun. Idempotency keys. State persistence across iterations. None of this is AI. All of it is what makes AI useful.

Draft with AI modal asking for campaign goal, audience, and tone
The visible magic is small because the system underneath is doing the work

If your automation breaks at 3 AM, it isn’t automation.

It’s a demo. Real AI automation is what runs without you. It handles rate limits. It retries with exponential backoff. It alerts when something genuinely needs your attention, and ignores the noise that doesn’t.


The gap between “cool AI demo” and “production AI automation” is where I live. It’s less glamorous than the Twitter threads suggest. It’s also the only part that actually pays.

If your team is drowning in spreadsheet chores, if your ops person is copy-pasting order IDs at midnight, if your dashboards are stale the moment you open them - that’s the problem worth solving. And the solution has AI in it. But mostly, it has craft.

Working on something like this?

Start a conversation →
Back to all notes