# Varstatt — Full Content > Concatenated markdown for every page on varstatt.com. 192 pages total. Each section starts with `# ` so quotes can be traced back to their source. For a structured index instead, see https://varstatt.com/llms.txt For individual pages, append `.md` to any URL or send `Accept: text/markdown`. --- # https://varstatt.com/ --- title: Ongoing Product Development on Weekly Retainer @ Varstatt url: https://varstatt.com description: Get consistent end-to-end web development without the agency hassle. Senior developer. Continuous progress. $997/week, cancel anytime. --- # Ongoing Product Development on Weekly Retainer Get consistent end-to-end web development [without the agency hassle](https://varstatt.com/principles). $997/week, cancel anytime. **What's included:** - ✅ Senior developer - ✅ Full code ownership - ✅ Continuous progress - ✅ Stop anytime - ❌ Meetings overhead - ❌ Scope creep - ❌ Agency markup - ❌ Billing surprises **Get started:** - [Fill project brief](https://varstatt.com/brief) - [15-min Discovery](https://varstatt.com/discovery) ## Portfolio - **Redesign & V2 development of SOAPNoteAI, an AI charting assistant for busy clinicians** — [soapnoteai.com](https://soapnoteai.com) - **Free, AI-powered technical discovery & development roadmap in 15 minutes** — [varstatt.com/discovery](https://varstatt.com/discovery) - **Initial launch of TenderPilot, an AI-powered tendering software for Australian SMEs** — [tenderpilot.ai](https://tenderpilot.ai) - **Content Pal, a pay-per-use social media content scheduler for solo creators** — [contentpal.app](https://contentpal.app) - **Fidder, a free & simple RSS reader that cuts through the noise and works everywhere** — [fidder.app](https://fidder.app) - **MVP & initial launch of MyCointainer, an ultimate crypto staking platform** — [mycointainer.com](https://web.archive.org/web/20200423203535/https://www.mycointainer.com/) ## How It Works A simple, predictable and transparent process that eliminates development headaches. 1. **Subscribe** — After subscribing, you get access to dedicated task board. This becomes our development roadmap. 2. **Setup** — I set up the development environment, bootstrap a fresh project or study your existing codebase. 3. **Plan & Develop** — You create tasks and set priorities. I work through them one-by-one and submit for review. 4. **Review & Refine** — You review the work and provide feedback. I revise until you're fully satisfied. 5. **Progress** — You approve completed work. I move to the next highest priority task. The cycle continues. 6. **Maintain** — You report issues anytime. I handle them outside the queue until subscription is active. ## Pricing — Production Fixed weekly retainer that eliminates budget overruns without contract or hidden costs. - **Price:** $997 / week - **Plan:** Weekly retainer provides senior expertise without agencies' overhead or freelancers' reliability issues. - **Includes:** - 🧑‍💻 10+ years building web apps - 🗣️ Direct access to developer - 🗂️ Dedicated task board - 📦 1 task at a time, unlimited revisions - ⚡ Delivery in ~72 hours on average - 🪪 Full code & assets ownership - 🩹 Bug fixes & ongoing maintenance - 🛑 Pause or cancel anytime - **CTA:** [Get Started](https://varstatt.com/brief) - **Questions:** jurij@varstatt.com ## FAQ ### What's the tech stack? JavaScript and TypeScript across the entire web development ecosystem — that's the hard limit. Within that, the [default stack](/principles/delivery/default-stack) is React, Next.js, Node.js, and Firebase, where years of accumulated expertise apply directly. The further your project sits from that default — different cloud, unfamiliar framework, exotic database — the less leverage that expertise provides. Varstatt evaluates this distance per project and only takes on work where it can deliver real value, not learn on your dime. ### What kinds of projects? Web applications, AI-powered apps, SaaS platforms, customer portals, [internal dashboards and admin panels](/jurij/p/what-an-automation-audit-looks-like), [MVPs](/jurij/p/what-a-6-week-mvp-build-looks-like), [proofs of concept](/jurij/p/what-a-2-week-poc-looks-like), automation tools, and APIs. ### Who is the retainer for? Founders and teams with an active product that needs ongoing development — bug fixes, new features, infrastructure improvements, week after week. Best fit: seed-stage or established startups that want consistent progress without hiring full-time or managing an agency. If you don't have a product yet, [a 2-week PoC](/jurij/p/what-a-2-week-poc-looks-like) validates the idea, or [a 6-week MVP build](/jurij/p/what-a-6-week-mvp-build-looks-like) ships the first real version — then move to the retainer for ongoing work. ### How much gets done in a week? It varies by complexity. Some weeks deliver major features, others focus on bug fixes or infrastructure. You get [continuous flow](/principles/delivery/continuous-flow), not fixed deliverables. Average task turnaround is 48-72 hours. ### What counts as one task? A single, focused piece of work — like *add user authentication*, *build contact form*, or *integrate Stripe payments*. Complex features get broken into multiple tasks. Scope is agreed during development, shaped by [appetite, not estimates](/principles/discovery/appetite-not-estimates). ### How are big features handled? Large features get broken down into smaller, reviewable tasks. This is [WIP one](/principles/delivery/wip-one) in practice: continuous progress and feedback throughout, instead of waiting weeks for a big delivery. ### Can I pause or cancel? Yes. Pause or cancel directly through the Stripe portal with no penalties or questions asked. Your subscription status updates immediately. This is part of Varstatt's [exit freedom](/principles/partnership/exit-freedom) commitment. ### How does the pause feature work? Billing stops immediately and your task board stays accessible (read-only). Resume anytime — Varstatt picks up where things left off, no re-onboarding. This is [pause, not end](/principles/diligence/pause-not-end) in practice: projects don't move in straight lines, so the engagement shouldn't pretend otherwise. Useful when you're waiting on decisions, content, or simply don't need development for a period. Bug fixes and maintenance are only included while actively subscribed. ### What if Jurij is unavailable? The subscription automatically pauses for those days at no charge. You're never billed for time Varstatt is unavailable. ### Who owns the code? You do — completely. [Client owns everything](/principles/philosophy/client-owns-everything) is set up from day one: repository under your account, every cloud service billed to your card, every API key in your name. Varstatt is a contributor, never an owner. If you cancel tomorrow, you lose nothing — hand the repo to another developer and keep going. Zero vendor lock-in, by design. ### How does communication work? [Async-first](/principles/partnership/async-first): WhatsApp, email, or Slack for quick questions; task board comments for detailed discussions about specific work. No daily standups, no status meetings, no calendar tetris. Meetings are reserved for genuine strategic decisions or major pivots, not tactical work. Messages get a response within a few hours; a separate channel exists for real emergencies (production down, data loss, security). ### What if the work isn't right? Varstatt revises based on your feedback until you're satisfied. Each task goes through review and refinement cycles at no additional cost. If we're fundamentally misaligned, cancel immediately with no penalty. ### Does the retainer work for a single task? Yes — subscribe, the task gets completed, cancel. For one-off scoped work, common shapes are: [code audit](/jurij/p/what-a-code-audit-looks-like), [Firebase audit](/jurij/p/what-a-firebase-audit-looks-like), [DevOps audit](/jurij/p/what-a-devops-audit-looks-like), or [automation audit](/jurij/p/what-an-automation-audit-looks-like) for focused reviews; [a 2-week PoC](/jurij/p/what-a-2-week-poc-looks-like) for a single isolated build. If you're unsure of scope, the [build cost & plan tool](/discovery/build-cost) helps map it out first. ### How do I get started? Fill the [project brief](/brief) (2-3 minutes), schedule a 15-minute discovery call to confirm fit, subscribe to the weekly plan, and Varstatt begins work the next business day. ## Testimonials > "Jurij understand the core problem, insists on building the right experiences for the customers." > — [Kunal Modi, Founder @ SOAPNoteAI](https://www.linkedin.com/in/jurijtokarski/details/recommendations/) > "Working with Jurij is an absolute joy, consistently exceeds expectations." > — [Janis Ozolins, Content Creator](https://x.com/OzolinsJanis/status/1875168034249146609) > "Consistently delivered quality code in great time, very hard working & precise." > — [Bartosz Poźniak, CEO @ MyCointainer](https://www.linkedin.com/in/jurijtokarski/details/recommendations/) > "The kind of engineer that every company on the planet would be lucky to have." > — [Hosam Mazawi, COO @ LemonUnit](https://www.linkedin.com/in/jurijtokarski/details/recommendations/) ## About Varstatt is run by [Jurij Tokarski](https://varstatt.com/jurij), product engineer since 2011. Contact: [jurij@varstatt.com](mailto:jurij@varstatt.com) / [X](https://x.com/varstatt) / [LinkedIn](https://www.linkedin.com/in/jurijtokarski/) Sole proprietorship headquartered in Rzeszów, Poland. VAT ID: PL8133854722. --- # https://varstatt.com/jurij --- title: Jurij Tokarski's Bits & Notes url: https://varstatt.com/jurij description: Recent posts on shipping software, building products, and solo development. section: Varstatt (https://varstatt.com) --- # Jurij Tokarski's Bits & Notes Showing 10 most recent posts. Full archive (https://varstatt.com/jurij/archive) has all 79. Tags: [principles-faq](https://varstatt.com/jurij/c/principles-faq), [project-stories](https://varstatt.com/jurij/c/project-stories), [ai](https://varstatt.com/jurij/c/ai), [retainer-shapes](https://varstatt.com/jurij/c/retainer-shapes), [software-design](https://varstatt.com/jurij/c/software-design), [software-delivery](https://varstatt.com/jurij/c/software-delivery), [debugging](https://varstatt.com/jurij/c/debugging), [solo-business](https://varstatt.com/jurij/c/solo-business), [firebase](https://varstatt.com/jurij/c/firebase), [validating-ideas](https://varstatt.com/jurij/c/validating-ideas) ## [Mirror Your Site as Markdown](https://varstatt.com/jurij/p/mirror-your-site-as-markdown) How varstatt.com serves every page as machine-readable markdown — prebuilt at deploy, discoverable via headers, and ready for agents. ## [404 Pages as Lead Magnets](https://varstatt.com/jurij/p/404-pages-as-lead-magnets) Fake Next.js crash overlay morphs into a chat. The broken URL becomes the first message. Every conversation pings my inbox. ## [SOAPNoteAI V2 Now Serves 100% of Users](https://varstatt.com/jurij/p/soapnoteai-v2-now-serves-100-percent-of-users) Eight months of building V1 and V2 side by side. The bridge mechanics, shipping continuously behind flags, and why stability — not features — gated the final cutover. ## [What an AI & Automation Retainer Looks Like](https://varstatt.com/jurij/p/what-ai-automation-retainer-looks-like) AI automation services on a weekly retainer — personal AI assistant setup, workflow scripts, ongoing maintenance. Same retainer, same person, same cadence as building. ## [What a White Label Web Development Retainer Looks Like](https://varstatt.com/jurij/p/what-whitelabel-dev-retainer-looks-like) White label web development on a weekly retainer — senior dev capacity behind an agency or design studio's brand, invisible to their clients. ## [Errors That Never Left The Device](https://varstatt.com/jurij/p/errors-that-never-left-the-device) Eleven silent failure paths in a clinical iOS app. None of them surfaced as a backend alert. ## [Works Locally, Fails After Deployment](https://varstatt.com/jurij/p/works-locally-fails-after-deployment) File tracing misses runtime paths, Cloud Run changes the working directory, CloudFront eats routes, and OG images silently vanish — four failures that pass every local test. ## [When the LLM Remembers Too Much](https://varstatt.com/jurij/p/when-llm-remembers-too-much) A vendor rate becomes a budget constraint and reasoning leaks across step boundaries — two failures from implicit state crossing where it shouldn't, and the architecture that fixed both. ## [What a Firebase Audit Looks Like](https://varstatt.com/jurij/p/what-a-firebase-audit-looks-like) Firebase consulting, structured as a one-week audit. Firestore queries, security rules, cost drivers — find what's exposing data and what's running up the bill. ## [What a Software Maintenance Retainer Looks Like](https://varstatt.com/jurij/p/what-a-software-maintenance-retainer-looks-like) Software maintenance services on a weekly retainer: dependencies, security, performance, monitoring, bug fixes, and new features — same diligence as building. --- # https://varstatt.com/jurij/archive --- title: Every Post So Far — Jurij Tokarski url: https://varstatt.com/jurij/archive description: All 79 posts on shipping software, building products, and figuring things out as a solo dev running Varstatt. section: Varstatt (https://varstatt.com) --- # Every Post So Far All 79 posts on shipping software, building products, and figuring things out as a solo developer running Varstatt. Tags: [principles-faq](https://varstatt.com/jurij/c/principles-faq), [project-stories](https://varstatt.com/jurij/c/project-stories), [ai](https://varstatt.com/jurij/c/ai), [retainer-shapes](https://varstatt.com/jurij/c/retainer-shapes), [software-design](https://varstatt.com/jurij/c/software-design), [software-delivery](https://varstatt.com/jurij/c/software-delivery), [debugging](https://varstatt.com/jurij/c/debugging), [solo-business](https://varstatt.com/jurij/c/solo-business), [firebase](https://varstatt.com/jurij/c/firebase), [validating-ideas](https://varstatt.com/jurij/c/validating-ideas) ## [Mirror Your Site as Markdown](https://varstatt.com/jurij/p/mirror-your-site-as-markdown) How varstatt.com serves every page as machine-readable markdown — prebuilt at deploy, discoverable via headers, and ready for agents. ## [404 Pages as Lead Magnets](https://varstatt.com/jurij/p/404-pages-as-lead-magnets) Fake Next.js crash overlay morphs into a chat. The broken URL becomes the first message. Every conversation pings my inbox. ## [SOAPNoteAI V2 Now Serves 100% of Users](https://varstatt.com/jurij/p/soapnoteai-v2-now-serves-100-percent-of-users) Eight months of building V1 and V2 side by side. The bridge mechanics, shipping continuously behind flags, and why stability — not features — gated the final cutover. ## [What an AI & Automation Retainer Looks Like](https://varstatt.com/jurij/p/what-ai-automation-retainer-looks-like) AI automation services on a weekly retainer — personal AI assistant setup, workflow scripts, ongoing maintenance. Same retainer, same person, same cadence as building. ## [What a White Label Web Development Retainer Looks Like](https://varstatt.com/jurij/p/what-whitelabel-dev-retainer-looks-like) White label web development on a weekly retainer — senior dev capacity behind an agency or design studio's brand, invisible to their clients. ## [Errors That Never Left The Device](https://varstatt.com/jurij/p/errors-that-never-left-the-device) Eleven silent failure paths in a clinical iOS app. None of them surfaced as a backend alert. ## [Works Locally, Fails After Deployment](https://varstatt.com/jurij/p/works-locally-fails-after-deployment) File tracing misses runtime paths, Cloud Run changes the working directory, CloudFront eats routes, and OG images silently vanish — four failures that pass every local test. ## [When the LLM Remembers Too Much](https://varstatt.com/jurij/p/when-llm-remembers-too-much) A vendor rate becomes a budget constraint and reasoning leaks across step boundaries — two failures from implicit state crossing where it shouldn't, and the architecture that fixed both. ## [What a Firebase Audit Looks Like](https://varstatt.com/jurij/p/what-a-firebase-audit-looks-like) Firebase consulting, structured as a one-week audit. Firestore queries, security rules, cost drivers — find what's exposing data and what's running up the bill. ## [What a Software Maintenance Retainer Looks Like](https://varstatt.com/jurij/p/what-a-software-maintenance-retainer-looks-like) Software maintenance services on a weekly retainer: dependencies, security, performance, monitoring, bug fixes, and new features — same diligence as building. ## [Null Bytes, Dead Streams, Last Chunk](https://varstatt.com/jurij/p/null-bytes-dead-streams-last-chunk) SSE adds overhead for mixed events, silent streams hang without error, and the last audio chunk vanishes on page close — three LLM streaming fixes. ## [200 OK, Data Wrong](https://varstatt.com/jurij/p/200-ok-data-wrong) Imagen rewrites your prompt, Lambda corrupts your binary buffer, GSC returns empty rows, and structured output truncates without error. ## [What a Code Audit Looks Like](https://varstatt.com/jurij/p/what-a-code-audit-looks-like) Code audit, software audit, technical due diligence — same work, different buyers. One week, $997, prioritized by risk: security, architecture, tech debt. ## [Filling Forms No Tool Can Template](https://varstatt.com/jurij/p/filling-forms-no-tool-can-template) Every tender form is different, templating tools need placeholders you can't insert, and markdown round-trips destroy the document. ## [What a PWA Build Looks Like](https://varstatt.com/jurij/p/what-a-pwa-build-looks-like) PWA development service in 6 weeks. The most budget-efficient way to ship an app: web + installable + app stores from one codebase. ## [SVG Animation Is Not DOM Animation](https://varstatt.com/jurij/p/svg-animation-is-not-dom-animation) Rebuilding an old chart race challenge in React taught me five things SVG handles differently than the DOM. Coordinates, transforms, text, and more. ## [What a Legacy App Modernization Looks Like](https://varstatt.com/jurij/p/what-a-legacy-app-modernization-looks-like) Legacy application modernization services in 6 weeks: feature-by-feature migration, live app throughout, no big-bang rewrite, no frozen branch. ## [45 Tabs I Stopped Opening](https://varstatt.com/jurij/p/45-tabs-i-stopped-opening) A JWT decoder, a mesh gradient engine, an animation system, and everything in between. Three of them outgrew the toolkit. ## [What a 2-Week PoC Looks Like](https://varstatt.com/jurij/p/what-a-2-week-poc-looks-like) Rapid prototyping service structured as a 2-week engagement. Validate the riskiest technical assumption, get a working prototype + go/no-go. ## [npm Publish Without Tokens](https://varstatt.com/jurij/p/npm-trusted-publishing-from-github-actions) Trusted publishing with OIDC replaces long-lived npm tokens. The setup has one undocumented requirement that returns a misleading 404. ## [It Works, But You Can't Ship It](https://varstatt.com/jurij/p/it-works-you-cant-ship-it) Two AI providers fill the form correctly. Both route document data through global endpoints that don't meet every customer's residency policy. ## [Three Ways the Wrong Value Won](https://varstatt.com/jurij/p/three-ways-the-wrong-value-won) A race condition, a stale default, and a spread operator each delivered the wrong value to production. None threw an error. ## [An Empty AI Response Corrupted Chat History](https://varstatt.com/jurij/p/silent-ai-response-corrupted-conversation-history) Gemini returned HTTP 200 with zero content. I saved the empty response to conversation history. The chat never recovered. Here's what went wrong. ## [How do you know if your idea is worth building?](https://varstatt.com/jurij/p/how-do-you-know-if-your-idea-is-worth-building) The discovery questions that separate good ideas from projects destined to fail. Demand validation, core features, and appetite-based scoping. ## [Why should you think of software as a business cost, not a craft?](https://varstatt.com/jurij/p/why-software-is-a-business-cost) Reframing software development from an art form to a predictable business expense changes how you build. ## [What a DevOps Audit Looks Like](https://varstatt.com/jurij/p/what-a-devops-audit-looks-like) DevOps audit in the original sense — how your team ships software. CI/CD, deployment, rollback, monitoring. Not cloud administration. ## [Software Engineering Principles for Startups](https://varstatt.com/jurij/p/software-engineering-principles-for-startups) 39 principles I use to ship software every week: a working system built from years of product development ## [Why Varstatt Uses Weekly Retainers, Not Sprints](https://varstatt.com/jurij/p/why-weekly-retainers-work-better-than-sprints) How continuous priority queue work differs from sprint-based development. Weekly retainers create accountability without the ceremony of sprints. ## [Why Scrum Fails In Small Teams](https://varstatt.com/jurij/p/why-scrum-fails-in-small-teams) Scrum was designed to coordinate large cross-functional teams. When your team is small enough to just talk, the ceremonies become the bottleneck. ## [What an Automation Audit Looks Like](https://varstatt.com/jurij/p/what-an-automation-audit-looks-like) Workflow automation service review in one week. Map internal portals, admin panels, and n8n / Make workflows — what you have, what's broken, what to consolidate. ## [Why do software projects fail?](https://varstatt.com/jurij/p/why-do-software-projects-fail) The root causes of scope creep, budget overruns, and misalignment that waste time and money. What goes wrong and how discovery prevents it. ## [Three Bugs That Were Actually My Prompts](https://varstatt.com/jurij/p/three-bugs-that-were-actually-my-prompts) Three debugging sessions where I chased AI misbehavior for hours. Each time the model was executing my instructions exactly as written. ## [What a Freelance Web Developer Actually Charges](https://varstatt.com/jurij/p/what-a-freelance-web-developer-actually-charges) My actual pricing model, why hourly billing is broken, and what clients should expect when hiring a freelance web developer in 2026. ## [Nobody Finishes a 15-Minute AI Interview](https://varstatt.com/jurij/p/nobody-finishes-a-15-minute-ai-interview) How I decomposed a monolithic AI discovery interview into 8 standalone tools — each with its own deliverable, landing page, and search intent. ## [What a Fractional CTO Engagement Looks Like](https://varstatt.com/jurij/p/what-a-fractional-cto-engagement-looks-like) Fractional CTO for early-stage startups: same person as the founding engineer who builds your first version. The retainer is the engagement. ## [How does software get shipped without breaking things?](https://varstatt.com/jurij/p/how-does-software-get-shipped-without-breaking-things) Continuous deployment, feature flags, monitoring from day one, and safe-to-fail design replace big-bang releases and lengthy QA cycles. ## [What happens when a client wants to stop or pause?](https://varstatt.com/jurij/p/what-happens-when-a-client-wants-to-stop-or-pause) Weekly billing, full client ownership, and no lock-in mean stopping or pausing is mechanically simple and penalty-free. ## [How does async collaboration work without daily meetings?](https://varstatt.com/jurij/p/how-does-async-collaboration-work-without-meetings) Async-first communication replaces status meetings with written updates, full transparency, and documentation that builds itself. ## [How should non-technical founders evaluate developers?](https://varstatt.com/jurij/p/how-should-non-technical-founders-evaluate-developers) You can't fully evaluate a developer before working with them. The goal is to minimize risk until you have enough real data. ## [What do most founders get wrong about building AI products?](https://varstatt.com/jurij/p/what-do-most-founders-get-wrong-about-ai-products) AI is a tool, not a product. What makes a product valuable is domain expertise. Without it, AI products are generic and replaceable. ## [The Production Bugs That Never Threw an Error](https://varstatt.com/jurij/p/production-bugs-that-never-threw-an-error) Six bugs across OAuth, Next.js, launchd, n8n, browser APIs, and OpenAI. Every log said success. Every result was wrong. ## [Firestore Transactions: Handling Race Conditions Between Cloud Functions](https://varstatt.com/jurij/p/using-firestore-transactions-to-handle-race-conditions) A runTransaction example for the case where two Cloud Function instances race to create the same external resource and one needs to win. ## [Merging Two Firestore Listeners for Cross-Field OR Queries](https://varstatt.com/jurij/p/merging-two-firestore-listeners-for-cross-field-or-queries) Firestore can't OR across different field types in a real-time query. Two parallel listeners merged client-side can. ## [When should you build custom vs buy off the shelf?](https://varstatt.com/jurij/p/when-should-you-build-custom-vs-buy-off-the-shelf) Default to buy. Build custom only when the custom part IS your product's value. Everything else is glue — and glue should come off the shelf. ## [How should a startup choose its tech stack?](https://varstatt.com/jurij/p/how-should-a-startup-choose-its-tech-stack) There is no best stack. There's a best stack for your specific situation. Match the stack to business reality, not trends. ## [How should founders think about SaaS development costs?](https://varstatt.com/jurij/p/how-should-founders-think-about-saas-development-costs) SaaS development isn't a one-time build. It's a weekly investment. The real question is how much per week you're willing to invest. ## [What's the difference between freelancer, agency, and retainer?](https://varstatt.com/jurij/p/whats-the-difference-between-freelancer-agency-and-retainer) Three service models with different incentive structures. The key insight: productize the workflow, not the service. ## [How much does it really cost to build an MVP?](https://varstatt.com/jurij/p/how-much-does-it-really-cost-to-build-an-mvp) The $5K-$500K ranges you see online are mostly noise. They reflect service model and overhead, not code quality. ## [SwiftUI Is Like React + CSS-in-JS](https://varstatt.com/jurij/p/swiftui-is-like-react-plus-css-in-js) I had to jump into an iOS codebase with no SwiftUI experience. The fastest way in was mapping everything I already knew to the Swift equivalent. ## [What makes a good project brief?](https://varstatt.com/jurij/p/what-makes-a-good-project-brief) A good brief describes the problem and the business, not the deliverable. Bad briefs say 'build me a mobile app.' ## [What a 6-Week MVP Build Looks Like](https://varstatt.com/jurij/p/what-a-6-week-mvp-build-looks-like) MVP development for startups, structured as a 6-week retainer. From idea to working SaaS — auth, core features, payments, production deploy. ## [How do you scope an MVP that ships in weeks, not months?](https://varstatt.com/jurij/p/how-do-you-scope-an-mvp-that-ships-in-weeks) Six-week appetite-based planning. Fix the time, flex the scope. Shape the solution to fit the constraint. ## [How do you go from idea to working product?](https://varstatt.com/jurij/p/how-do-you-go-from-idea-to-working-product) The real path from idea to product is discovery of the problem first, then solution. Most founders skip this entirely. ## [Your Market Tells You Who You Are](https://varstatt.com/jurij/p/your-market-tells-you-who-you-are) Launched as an MVP shop. Clients stayed for 12 months. Eventually I listened to what the market was telling me about who I actually am. ## [Being Good To Be Referred](https://varstatt.com/jurij/p/being-good-to-be-referred) Split your business brand (clients) from personal brand (peers). Build referral relationships through authentic content, not polished marketing. ## [The Fourth Evolution of MVP](https://varstatt.com/jurij/p/the-fourth-evolution-of-mvp) Frank Robinson invented MVPs in 2001. Eric Ries redefined them in 2011. AI is redefining them again in 2025. ## [Using Shared Packages in Firebase Monorepos](https://varstatt.com/jurij/p/using-shared-packages-in-firebase) Firebase breaks with file:../shared deps. Use npm pack + tarball approach: preinstall script creates .tgz locally, gets included in deploy. ## [Build Apps Like LEGO Bricks](https://varstatt.com/jurij/p/build-apps-like-lego-bricks) Use ports & adapters architecture to swap AI providers like LEGO bricks. Avoid vendor lock-in, optimize costs, test new models easily. ## [When "Polish Over Security" Costs Real Money](https://varstatt.com/jurij/p/when-polish-over-security-costs-real) Client wanted to "polish features first, security later." Found exposed OpenAI API key in frontend code. Anyone could steal it and rack up unlimited charges. ## [Fidder Overengineering Made Me Pay](https://varstatt.com/jurij/p/fidder-overengineering-made-me-pay) Reduced Fidder's maintenance costs $17/month → $1/month by fixing architectural mistakes, like expensive Firestore secutiry rules and unnecessary VPS-polling. ## [If You Throw Away Your MVP Code, It Wasn't an MVP](https://varstatt.com/jurij/p/if-you-throw-away-your-mvp-code-it) 6-day "MVPs" are prototypes disguised as products. Real MVPs use foundation-first architecture for extension, not throwaway code. ## [When Optimization Culture Breaks Human Judgment (Digest)](https://varstatt.com/jurij/p/when-optimization-culture-breaks) A reading digest on how systems designed to optimize metrics are undermining the human capabilities that actually matter. ## [I renamed (again) my newsletter (and why)](https://varstatt.com/jurij/p/i-renamed-again-my-newsletter-and) Changed "Self × Tech" to "Jurij's Workshop" because of etymology and cultural connections; here's the messy process ## ["It Works" Isn't Enough for Commercial Software](https://varstatt.com/jurij/p/it-works-isnt-enough-for-commercial) Vibe-coding excels for personal projects but creates dangerous technical debt in commercial products that require human-designed architecture and oversight ## [How We Built a Zero-Cost CMS Portfolio That Actually Works](https://varstatt.com/jurij/p/how-we-built-a-zero-cost-cms-portfolio) Using Next.js, Airtable, and Vercel's free tiers, we built a content-managed portfolio site with zero ongoing costs and hourly updates. ## [Simple E-Commerce Request Uncovered a Wholesale Business's True Need](https://varstatt.com/jurij/p/simple-e-commerce-request-uncovered) What began as an e-commerce project revealed a deeper need: tracking products across the entire supply chain, not just processing orders ## [The Tiny App That Eliminated a Shipping Processing Bottleneck](https://varstatt.com/jurij/p/the-tiny-app-that-eliminated-a-shipping) Custom app cut shipping preparation time from 10 minutes to 60 seconds, slashed errors by 80%, and paid for itself in 30 days — all for $3/month ## [Building an Embeddable DIY Diamond Mosaic PDF Generator](https://varstatt.com/jurij/p/building-an-embeddable-diy-diamond) Created a web app that transforms photos into diamond art patterns using client-side processing, eliminating server costs while enhancing UX ## [The Algorithm that Transformed Warehouse Chaos](https://varstatt.com/jurij/p/the-algorithm-that-transformed-warehouse) Reduced shipment prep time by 70%, improved accuracy to 95%, and cut costs by 40%. Five years later, it's still the company's operational backbone. ## [Domain Experts Still Need Tech Partners (Even in AI Era)](https://varstatt.com/jurij/p/domain-experts-still-need-tech-partners) Industry experts know the problems; tech partners know how to build solutions. Why domain knowledge alone isn't enough to ship a software product. ## [Newsletter Framework / Service Evolution / Open Source Journey](https://varstatt.com/jurij/p/newsletter-framework-service-evolution) New weekly newsletter format, pivoting to rapid prototyping services, and open-sourcing Fidder and Content Pal (4 min read) ## [Messy Truth > Perfect Lie](https://varstatt.com/jurij/p/messy-truth-perfect-lie) Relaunching my personal blog with a new direction. What comes next, what didn't work, and choosing messy truth over polished performance. ## [Start Now; Iterate and Saturate Later](https://varstatt.com/jurij/p/start-now-iterate-and-saturate-later) A project that survives starts with the core value, adapts to real feedback, and improves over time. Why launching early beats planning forever. ## [A Story of Fidder: The RSS Reader Without the Noise](https://varstatt.com/jurij/p/a-story-of-fidder-the-rss-reader) The story behind Fidder, an RSS reader built to follow your favorite sites without the social media noise, algorithms, or distraction. ## [A Launch Story of MyCointainer: Ultimate Staking Service Platform](https://varstatt.com/jurij/p/a-launch-story-of-mycointainer-ultimate) The launch story of MyCointainer: a staking platform where users choose crypto assets, transfer coins to a staking wallet, and earn passive rewards. ## [Deliver Ideas To Market With Applied R&D](https://varstatt.com/jurij/p/deliver-ideas-to-market-with-applied) Applied R&D allows entrepreneurs to innovate quickly, adapt to changing conditions, and solve real-world problems efficiently. ## [Workflow Automation Is Business' Superpower](https://varstatt.com/jurij/p/workflow-automation-is-business-superpower) Workflow automation transforms how businesses operate, replacing manual effort with technology to execute recurring tasks. ## [Smooth Operator of Software Development Lifecycle](https://varstatt.com/jurij/p/smooth-operator-of-software-development) Everything should have its place and time, and the engineering work should flow. How to structure delivery so nothing blocks and nothing drifts. ## [Streamlining Software Release Process: A Case Study](https://varstatt.com/jurij/p/streamlining-software-release-process) Automating releases transforms them from error-prone, anxiety-inducing events into confident, one-click routines that teams like to perform --- # https://varstatt.com/principles --- title: Engineering and Delivery Principles @ Varstatt url: https://varstatt.com/principles description: 39 principles on software as business cost, weekly retainer over sprints, async-first, and production-ready delivery. section: Varstatt (https://varstatt.com) --- Most software projects fail the same way. Unclear scope turns six weeks into six months. A $15K budget becomes $50K. Developers build the wrong thing beautifully, on time, exactly as specified. After building software since 2011, five areas separate work that ships from work that drifts: Discovery, Delivery, Diligence, Partnership, and Philosophy. ## Discovery Not everything that could be built should be. Discovery answers the question nobody wants to slow down for: what's actually worth building? The developer finds the core feature — the one thing without which nothing else matters. Appetite-based boundaries replace estimates. Estimates are guesses dressed up as commitments. Appetite is honest: this is how much the client is willing to spend on this problem. Technology choices follow context, not trends. Business constraints trump technical purity every time. The developer shapes scope to fit the appetite — and says no when a project isn't the right fit. ## Delivery Delivery is continuous flow, not sprint theater. One WIP limit: one thing in progress at a time. "Done" means deployed to production, not merged, not reviewed, not demoed. The developer refactors as they go. Leave the code better than it was found. Feature flags keep the main branch production-ready so there's no release anxiety, no freeze periods, no coordination overhead. Regular async updates replace meetings. No meetings unless a strategic decision genuinely requires one. Most decisions don't. Quality comes from safe-to-fail design, not from preventing every bug. And a default stack means compounding expertise, not starting from scratch each time. ## Diligence Software evolves or dies. There's no finish line, no handoff moment where development ends and maintenance begins. Diligence rejects that split entirely. Monitoring starts on day one. The developer observes actual behavior, analyzes real patterns, and improves based on what's happening — not what anyone assumed would happen during the build. The client can pause when work slows and resume when it picks up. No lock-in, no retainer theater. When production breaks, the developer responds immediately — patch, fix, update. Documentation happens automatically through transparent process, not as a separate deliverable. ## Partnership None of this works without the right relationship model. The relationship starts with a week zero — a mutual audition before real money changes hands. The price is public, the model self-selects. The client sees everything — the task board, the repository, the messy commits. Full transparency, not curated updates. Weekly billing creates accountability without lock-in. Four natural exit points every month. Easy cancellation forces quality. There's no scope creep because there's no fixed scope — just a priority queue the client and developer work through together. ## Philosophy Software development is a business cost. Like paper in the office. Not special, not art. That means it should be predictable, low-friction, and cost-effective. Hours are the wrong metric. Platform consolidation beats integration complexity. The solo developer model — one person, full stack, AI-augmented — is a competitive advantage, not a limitation. The client owns everything: the repo, the services, the infrastructure. Zero lock-in by design. The client decides when something is done — not the developer. --- # https://varstatt.com/principles/chat --- title: Ask the Handbook with AI @ Varstatt Principles url: https://varstatt.com/principles/chat description: Chat with the Varstatt Principles Handbook. Ask any question about software delivery, discovery, and partnership — answered from the handbook. section: Principles (https://varstatt.com/principles) --- # Ask the Handbook with AI An interactive AI chat grounded in the 39 Varstatt Principles. Ask any question about software delivery, discovery, diligence, partnership, or philosophy — and get answers sourced from the handbook. This page requires a browser to use. The underlying handbook content is available as markdown: - [Principles index](https://varstatt.com/principles.md) - Individual principles at `https://varstatt.com/principles/{category}/{slug}.md` — e.g., [default-stack](https://varstatt.com/principles/delivery/default-stack.md) ## What you can ask - How should I scope an MVP without overbuilding? - Why weekly retainer instead of fixed-bid projects? - What does 'production is done' mean in practice? - How do you approach code review with async-first teams? ## Related - [Principles hub](https://varstatt.com/principles) - [Founder Notes (blog)](https://varstatt.com/jurij/archive) - [Project Brief](https://varstatt.com/brief) --- # https://varstatt.com/toolkit --- title: Free Developer Toolkit @ Varstatt url: https://varstatt.com/toolkit description: 48+ free browser-based developer tools. No signup required. section: Varstatt (https://varstatt.com) --- # Free Developer Toolkit 48+ free browser-based tools across encoding, text, design, network, and security. No signup. ## [Barcode Generator](https://varstatt.com/toolkit/barcode) Generate Code 128, EAN-13, UPC-A, and more barcodes online. Customize bar width, height, and font. Download as SVG or PNG. ## [Base64 Encoder/Decoder](https://varstatt.com/toolkit/base64) Free Base64 encoder and decoder for text, files, and images. Convert images to data URIs. Preview decoded images. All client-side. ## [Case Converter](https://varstatt.com/toolkit/case) Convert text between camelCase, snake_case, kebab-case, PascalCase, Title Case, SCREAMING_SNAKE_CASE, and more. All variants at once. ## [Copy Paste Characters](https://varstatt.com/toolkit/chars) Click to copy special characters, symbols, arrows, math operators, Greek letters, and currency signs. Plain Unicode, works everywhere. ## [Area Chart Race](https://varstatt.com/toolkit/chart-area) Animated stacked area chart showing composition changes over time. Stacked and percentage modes. CSV data, fullscreen presentation. ## [Bar Chart Race](https://varstatt.com/toolkit/chart-bar) Create animated bar chart race visualizations from CSV data. Watch categories compete and reorder over time. Fullscreen presentation mode. ## [Bubble Chart Race](https://varstatt.com/toolkit/chart-bubble) Animated bubble chart where circle sizes represent values. Watch bubbles grow and shrink over time. CSV data, fullscreen mode. ## [Line Chart Race](https://varstatt.com/toolkit/chart-line) Animated line chart that draws progressively over time. Also known as horserace or bump chart. CSV data, fullscreen presentation mode. ## [Color Converter](https://varstatt.com/toolkit/color) Convert between HEX, RGB, HSL, and OKLCH. WCAG contrast checker, complementary palette generator, and CSS-ready copy. ## [Image Converter](https://varstatt.com/toolkit/convert) Convert images between PNG, JPEG, WebP, and more. Batch processing with quality and resize controls. All client-side, no upload. ## [CORS Tester](https://varstatt.com/toolkit/cors) Check CORS headers and security headers for any URL. See Access-Control headers, X-Frame-Options, HSTS, CSP, and more. ## [CSS Cover Art Generator](https://varstatt.com/toolkit/covers) Generate abstract, geometric CSS-only cover art. 8 pattern styles, customizable colors and complexity. Export as HTML/CSS or PNG. ## [Crontab Generator](https://varstatt.com/toolkit/crontab) Build and parse cron expressions visually. Human-readable description and next 5 scheduled runs. Copy for crontab, CI/CD, or schedulers. ## [CSV Editor](https://varstatt.com/toolkit/csv) Visual spreadsheet editor for CSV files. Sort, filter, search & replace, add/remove rows and columns. Export as CSV or TSV. ## [Text Diff](https://varstatt.com/toolkit/diff) Compare two texts side-by-side or inline with character-level diff highlighting. Additions, deletions, and changes shown in real time. ## [DNS Lookup](https://varstatt.com/toolkit/dns) Look up A, AAAA, MX, NS, TXT, CNAME, and SOA records for any domain. Results grouped by record type with one-click copy. ## [Encrypt / Decrypt](https://varstatt.com/toolkit/encrypt) Encrypt and decrypt text with a passphrase using AES-256-GCM. Client-side authenticated encryption — nothing is sent to a server. ## [Favicon Generator](https://varstatt.com/toolkit/favicon) Generate favicons in all standard sizes from a single image. Get HTML link tags and web manifest JSON. All client-side. ## [Hash Generator](https://varstatt.com/toolkit/hash) Generate MD5, SHA-1, SHA-256, SHA-384, and SHA-512 hashes for text and files. Verify integrity with hash comparison. All client-side. ## [HTML ↔ Markdown](https://varstatt.com/toolkit/html-md) Convert between HTML and Markdown instantly. Configurable heading style, list markers, and link format. Copy or download the result. ## [HTTP Status Codes](https://varstatt.com/toolkit/http) Complete HTTP status code reference. Search by code or name, click to copy. Grouped by category with plain-English descriptions. ## [Image to Base64](https://varstatt.com/toolkit/img2b64) Convert images to Base64 data URIs, HTML img tags, and CSS background-image properties. Reverse mode included. All client-side. ## [JSON ↔ YAML Converter](https://varstatt.com/toolkit/json-yaml) Convert between JSON and YAML instantly. Live bidirectional editing with syntax highlighting, validation, and copy support. ## [JSON Formatter & Validator](https://varstatt.com/toolkit/json) Free JSON formatter, validator, and beautifier. Pretty-print with syntax highlighting, minify, validate with error details, and tree view. ## [JWT Decoder](https://varstatt.com/toolkit/jwt) Free online JWT decoder. Decode JWT tokens into header, payload, and signature with expiration status. All client-side. ## [Loopkit](https://varstatt.com/toolkit/loopkit) Schema-driven SVG animation engine. Under 5KB, zero dependencies. Works with React, SSR, and vanilla JS. Also on npm. ## [Markdown Repository](https://varstatt.com/toolkit/markdown-repository) Query .md and .mdx files by frontmatter with a Firestore-style API. Works with Next.js App Router and server components. ## [Markdown to DOCX](https://varstatt.com/toolkit/md-docx) Convert Markdown to Word documents (.docx). Custom fonts, colors, and layout. Opens in Word, Pages, Google Docs, and LibreOffice. ## [Markdown to PDF](https://varstatt.com/toolkit/md-pdf) Convert Markdown to beautifully formatted PDF. Custom fonts, colors, page size, margins, and line height. All in your browser. ## [Markdown Preview](https://varstatt.com/toolkit/md) Live Markdown editor with GitHub Flavored Markdown support. Synchronized scrolling, syntax highlighting, and HTML export. ## [Mesh Gradient Generator](https://varstatt.com/toolkit/mesh) Create beautiful mesh gradients with custom colors and positions. Export as CSS background-image or PNG at custom resolution. ## [OG Tag Validator](https://varstatt.com/toolkit/og) Validate Open Graph and Twitter Card meta tags. Preview how links appear on Facebook, LinkedIn, and Twitter/X. Check missing tags. ## [Password Generator](https://varstatt.com/toolkit/password) Generate strong passwords with customizable length, character sets, and strength meter. Uses Web Crypto API for true randomness. ## [Image Placeholder Generator](https://varstatt.com/toolkit/placeholder) Generate placeholder images with custom size, colors, and text. Download as PNG or SVG, or copy the data URI. No external service. ## [QR Code Generator](https://varstatt.com/toolkit/qr) Generate QR codes for URLs, WiFi, vCards, phone, email, and SMS. Custom colors and error correction. Download as SVG or PNG. ## [Aspect Ratio Calculator](https://varstatt.com/toolkit/ratio) Calculate aspect ratios, resize dimensions proportionally, and visualize common ratios like 16:9 and 4:3. Instant copy. ## [Regex Tester](https://varstatt.com/toolkit/regex) Test regular expressions with live highlighting, capture groups, pattern explanation, and quick-reference. Export in JS, Python, or Go. ## [Robots.txt Validator](https://varstatt.com/toolkit/robots) Validate robots.txt syntax and test URL paths against crawl rules. Check which pages are allowed or blocked for any user-agent. ## [Sitemap Validator](https://varstatt.com/toolkit/sitemap) Validate XML sitemaps for correct structure, URLs, dates, and priorities. Find errors and warnings instantly. Supports urlset and sitemapindex. ## [Slug Generator](https://varstatt.com/toolkit/slug) Generate clean, URL-safe slugs from any text. Customize separators, transliterate accents, set max length. Live preview. ## [SSL Certificate Checker](https://varstatt.com/toolkit/ssl) Check SSL/TLS certificate details for any domain. Expiry dates, issuer, Subject Alternative Names, protocol, and cipher suite. ## [SVG Optimizer](https://varstatt.com/toolkit/svg) Optimize and minify SVG files in your browser. Remove metadata, collapse whitespace, shorten colors, and see size savings instantly. ## [Text to Gradient](https://varstatt.com/toolkit/text-gradient) Generate unique mesh gradients from any text. Same input always produces the same gradient. Export as CSS or PNG. ## [Unix Timestamp Converter](https://varstatt.com/toolkit/timestamp) Convert between Unix timestamps and human-readable dates. Auto-detects seconds vs milliseconds. Timezone support. ## [User Agent Parser](https://varstatt.com/toolkit/ua) Parse any user agent string into browser, OS, engine, and device details. Auto-detects your current browser on load. ## [UUID Generator](https://varstatt.com/toolkit/uuid) Generate UUID v1, v4, and v7 online. Bulk generation up to 1,000 UUIDs. Toggle uppercase, hyphens. Export as TXT or CSV. ## [Word & Character Counter](https://varstatt.com/toolkit/words) Count words, characters, sentences, and paragraphs. Estimate reading and speaking time. Live updates as you type. ## [YAML Validator](https://varstatt.com/toolkit/yaml) Validate YAML syntax with inline errors and line numbers. Tree view, auto-format, and common template samples for Kubernetes, Docker, and GitHub Actions. --- # https://varstatt.com/discovery --- title: AI Discovery Tools for Founders @ Varstatt url: https://varstatt.com/discovery description: 8 free AI-powered founder tools for technical discovery. No signup. section: Varstatt (https://varstatt.com) --- # AI Discovery Tools 8 free AI-powered tools for founders building software products. No signup required. Prefill any tool with context: `?context=I+am+building+...` ## [Generate a Lean Canvas for Software](https://varstatt.com/discovery/lean-canvas) Free AI lean canvas generator for software founders. Each box gets challenged — no empty filler. 3 minutes, no signup, opinionated output. ## [Map the Competitive Landscape](https://varstatt.com/discovery/competitive-analysis) Free AI competitor research tool. Maps direct, indirect, and substitute competitors and pushes back when you say you have none. 3 minutes. ## [Generate Software User Personas](https://varstatt.com/discovery/user-personas) Free AI user persona generator built for software products. Get jobs-to-be-done, evaluation behaviors, and churn signals — not stock photos. ## [Plan Pricing and Distribution](https://varstatt.com/discovery/market-distribution) Free AI tool for distribution planning: willingness-to-pay band, ranked channels, first tests, trip-wires. Practical reach planning, no TAM SAM SOM theater. ## [Sort Features Into What Ships First](https://varstatt.com/discovery/feature-priorities) Free AI feature prioritization matrix. Classifies features as core, supporting, or generic — then phases them into MVP, Growth, and Scale. ## [Tech Strategy for Software Builds](https://varstatt.com/discovery/tech-strategy) Free AI build vs buy advisor and tech stack recommender for startups. Built by a senior developer who ships, not a stack listicle. ## [Write a Developer-Ready PRD](https://varstatt.com/discovery/project-scope) Free AI PRD generator. Turns your idea into a developer-ready spec with user stories and acceptance criteria. Works with Cursor and Claude Code. ## [Estimate Build Cost and Timeline](https://varstatt.com/discovery/build-cost) Free AI app development cost calculator. Real numbers, 2-3x reality check, 6-week action plan. No contact form, no agency upsell. --- # https://varstatt.com/brief --- title: Project Brief @ Varstatt url: https://varstatt.com/brief description: Share your project details in a quick 3-5 minute conversation. Get a structured brief sent to your email — ready to discuss with a developer. section: Varstatt (https://varstatt.com) --- # Project Brief An AI-guided conversation that collects your project details in 3–5 minutes. You chat, the AI asks clarifying questions, and a structured brief is emailed to you — ready to discuss with a developer. This page requires a browser to use. It creates a session, streams questions, and persists your responses. ## What the brief collects - **Overview** — what you're building, in one sentence - **Motivation** — why now, why you - **Problem** — what you're solving - **Audience** — who it's for - **Key Features** — the core capabilities - **Platform** — web, mobile, desktop, or combinations - **Differentiator** — what makes it different from alternatives - **Timeline** — when you need to ship - **Budget** — your appetite for the work - **Assets** — what you already have (designs, content, code, data) - **References** — competitors or inspirations - **Constraints** — technical, legal, or business constraints ## Prefill context You can seed the conversation via URL: `https://varstatt.com/brief?context=I+am+building+...` (max 2000 chars). ## After submission You get a copy of the brief by email. You can also deep-dive into the idea with the free [AI Discovery Tools](https://varstatt.com/discovery) — business model canvas, competitive analysis, feature prioritization, and more — seeded with your brief context. ## Related - [Home — Ongoing Development Retainer](https://varstatt.com/) - [AI Discovery Tools](https://varstatt.com/discovery) --- # https://varstatt.com/jurij/p/mirror-your-site-as-markdown --- title: Mirror Your Site as Markdown url: https://varstatt.com/jurij/p/mirror-your-site-as-markdown author: Jurij Tokarski date: 2026-05-28 description: How varstatt.com serves every page as machine-readable markdown — prebuilt at deploy, discoverable via headers, and ready for agents. section: Blog (https://varstatt.com/jurij/archive) tags: ai (https://varstatt.com/jurij/c/ai), software-design (https://varstatt.com/jurij/c/software-design), solo-business (https://varstatt.com/jurij/c/solo-business) --- Cloudflare runs [isitagentready.com](https://isitagentready.com/varstatt.com?profile=content) — a scorecard that grades sites on how well they expose themselves to AI agents. The tool covers five categories (discoverability, content accessibility, bot access control, protocol discovery, commerce), but varstatt.com is a content site, so I ran the `?profile=content` view and focused on what it flagged there. I took the gaps as a checklist, shipped what was useful. This post is what came out of that. ## The Problem with HTML for Agents An LLM that lands on a marketing page sees layout markup — divs, buttons, style tags. It has to parse that to extract the content — pricing, FAQ, what the page is selling. It often gets it wrong, or burns tokens guessing. The fix isn't to rebuild the page. The fix is to expose the same content in a format an agent can read directly. Markdown is the obvious choice — it's what every model was trained on, and it strips the layout noise without losing structure. The HTML version is for humans. The `.md` version is the same content, stripped of layout, with stable URLs and structured frontmatter. ## Same Content, 10× Fewer Tokens | Page | HTML | Markdown | Reduction | | -------------------- | ------ | -------- | --------- | | [Homepage](https://varstatt.com/) | 23,908 | 2,130 | 11× | | [JSON Formatter](https://varstatt.com/toolkit/json) | 8,315 | 463 | 18× | | [Code audit blog post](https://varstatt.com/jurij/p/what-a-code-audit-looks-like) | 16,699 | 2,159 | 8× | An agent that wants to know what varstatt.com sells either burns 24K tokens parsing layout, or fetches 2K tokens of clean structured markdown. The [interactive toolkit tool](https://varstatt.com/toolkit/json) is the most extreme — the HTML ships an entire React app for what is, in markdown, a FAQ and a how-it-works list. ## Two Entry Points Agents discover the markdown mirror two ways. **URL suffix.** `/jurij/p/what-a-code-audit-looks-like.md` returns the markdown for that page. Predictable, cacheable, works in any HTTP client. **Accept negotiation.** `Accept: text/markdown` on the same URL returns the same markdown. The original URL stays canonical. Both routes go through middleware that rewrites them to a single static endpoint: ```javascript // /foo.md → /api/markdown/foo if (pathname.endsWith(".md")) { const contentPath = pathname.slice(0, -3) || "/"; return rewriteToMarkdown(request, contentPath); } // Accept: text/markdown → /api/markdown/ if (accept.includes("text/markdown") && isContentPath(pathname)) { return rewriteToMarkdown(request, pathname); } ``` ## Prebuilt at Deploy The markdown route is a Next.js catch-all with `force-static` and `generateStaticParams`. Every known content URL — homepage, blog posts, principles, packages, discovery tools, toolkit entries — gets prerendered at `next build` and served as a static file from the CDN. ```javascript export const dynamic = "force-static"; export const dynamicParams = true; export async function generateStaticParams() { return getAllPagePaths().map((path) => { if (path === "/") return { path: undefined }; return { path: path.replace(/^\//, "").split("/") }; }); } ``` 170 paths prebuilt. The route handler runs once per page at build time, never at request time. No serverless function cost, no cold starts, no runtime errors. `dynamicParams: true` means unknown paths still hit the handler — those return a markdown 404 with links to main sections, so even an agent that guesses a wrong URL gets useful context back. ## Mirroring JSX Pages The package pages — [`/`](https://varstatt.com/), [`/jurij/p/what-a-6-week-mvp-build-looks-like`](https://varstatt.com/jurij/p/what-a-6-week-mvp-build-looks-like), etc. — are MDX files that compose React components. No markdown body. The content lives in component props: ``, ``, ``. To produce a real markdown mirror, the renderer evaluates the MDX with `@mdx-js/mdx`, then walks the React tree. Each section component has a markdown stub — a function that takes the same props and returns a string: ```javascript function Pricing({ preset }) { const data = PRICING_PRESETS[preset]; return [ `## ${data.title} — ${data.planLabel}`, `- **Price:** ${data.price} ${data.priceUnit ?? ""}`, ...data.includes.map((i) => ` - ${i}`), ].join("\n"); } ``` MDX creates React elements with these stub functions as `type`, but never invokes them. The walker calls each one manually, joins the results with blank lines. The output is a faithful mirror of the page — same hero, same pricing, same FAQ — as markdown a human could read like a GitHub README. ## Toolkit Tools — Render from Frontmatter The 48 [toolkit tools](https://varstatt.com/toolkit) (Base64, JSON formatter, regex tester, and so on) had a different shape. Each MDX file is mostly empty in the body — ``, ``, `` — with all the content in YAML frontmatter: ```yaml howItWorks: - step: "Choose text or file mode" description: "Use the Text tab for string encoding..." faqs: - q: "What is Base64?" a: "Base64 encodes binary data as ASCII text..." ``` The markdown handler skips MDX evaluation entirely for tools and renders straight from frontmatter. Each tool gets a structured markdown page: description, How It Works, FAQ, Usage, Related Tools. The Usage section adds something specific to interactive tools: a note that the tool needs a browser (since markdown can't show a working JSON formatter), plus a URL prefill example for tools that support it: ``` ## Usage This tool runs entirely in the browser — visit the URL above to use it. Prefill inputs via URL parameters: - `https://varstatt.com/toolkit/regex?pattern=...&flags=...&input=...` ``` Discovery tools get the same treatment — they're AI chats that need a browser, so the markdown explains the tool, lists the questions an agent could prepare answers for, and shows how to prefill the chat with `?context=...`. ## llms.txt and llms-full.txt `llms.txt` follows the [llmstxt.org](https://llmstxt.org) convention — a structured index of what's on the site. Services, portfolio, [discovery tools](https://varstatt.com/discovery) (with prefill hints), toolkit, [principles](https://varstatt.com/principles), recent blog posts. 170 lines, 14 KB. Generated dynamically from the same content sources as the rest of the site, so it never goes stale. `llms-full.txt` is the optional companion: every page's markdown concatenated into one file. 12,459 lines, 651 KB. Each section starts with `# ` so any quoted snippet can be traced back to its source page. Both are prebuilt static files. The index for navigation, the full file for one-shot loading. ## Make the Markdown Discoverable An agent that hits the HTML version doesn't always know the markdown exists. RFC 8288 has a header for that: ``` Link: ; rel="alternate"; type="text/markdown" ``` Middleware adds it to every HTML response. A HEAD request reveals the markdown URL without any guessing. The agent can switch over without re-fetching. ## Robots.txt with Content Signals Cloudflare's [Content Signals](https://developers.cloudflare.com/cache/content-signals/) spec lets you declare intent for bot use: `search`, `ai-input`, `ai-train`. One line in `robots.txt`: ``` User-agent: * Content-Signal: search=yes, ai-input=yes, ai-train=yes Allow: / ``` Then explicit allow blocks for GPTBot, ClaudeBot, PerplexityBot, Google-Extended, and the other AI crawlers. Explicit beats implicit when the spec is still settling. ## What I Skipped Premature standards adoption is one of the worst forms of tech debt — you can't refactor away from a public protocol once agents start consuming it. Worth shipping: markdown negotiation, llms.txt, llms-full.txt, Link headers, Content Signals, AI bot allowlist. Skipped: MCP Server Card, WebMCP, Agent Skills, x402, MPP, UCP, ACP. All early specs with no real adoption yet. I'll revisit when they settle. ## What This Costs The whole thing is static. Build time goes up by a few seconds while 170 markdown files get generated. Runtime cost is zero — every `.md` URL serves from CDN cache. No serverless function invocations, no streaming, no on-demand rendering. If you ship content from a git repo and rebuild on push, this is the right shape: one renderer, one shared content source, one build step, every page mirrored. ## What an Agent Sees Now Three things changed: 1. Every page has a `.md` URL with the full content in clean markdown 2. The HTML version advertises its `.md` mirror via `Link` header 3. `llms.txt` indexes everything; `llms-full.txt` concatenates everything An agent that lands on the homepage knows where to find the markdown without trying URLs. An agent that wants context loads one file. An agent that wants a specific page fetches one URL. None of it requires the agent to parse HTML. The site stayed the same for humans. For agents, it became a different surface — one they can actually read. --- # https://varstatt.com/jurij/p/404-pages-as-lead-magnets --- title: 404 Pages as Lead Magnets url: https://varstatt.com/jurij/p/404-pages-as-lead-magnets author: Jurij Tokarski date: 2026-05-20 description: Fake Next.js crash overlay morphs into a chat. The broken URL becomes the first message. Every conversation pings my inbox. section: Blog (https://varstatt.com/jurij/archive) tags: ai (https://varstatt.com/jurij/c/ai), solo-business (https://varstatt.com/jurij/c/solo-business) --- The 404 page is the one page on a site that only appears when something went wrong. The default response — "page not found, go home" — confirms the failure and the user bounces. Tom Orbach [made the case](https://www.marketingideas.com/p/how-to-get-leads-from-your-404-page) that 404 pages should be lead magnets — especially now that AI hallucinations are sending real traffic to URLs that never existed. His version: a branded gift in exchange for an email. I wanted mine to do something different. ## The Fake Crash Hit a broken URL on varstatt.com and you see a pixel-perfect replica of the Next.js dev error overlay. Red bar, stack trace, source preview. For about half a second, you think the site is broken. Then the bar turns amber. A braille spinner appears. The text changes to "Handling the issue..." After a beat, the bar turns green, the message reads "Handled", and the whole thing morphs into a chat. Under three seconds. Fast enough that you don't leave, slow enough to notice what happened. The "Handling" state has a minimum dwell of 500ms even if the AI responds in 80ms — without it, the morph feels fake. ## The URL Is the First Message The chat doesn't ask "how can I help?" It reads the URL path as the question: - `/pricing` → talks about the retainer - `/json` → links to the [JSON formatter](https://varstatt.com/toolkit/json) - `/hire` → explains how to engage - `/joke-of-the-day` → tells you a joke The system prompt names the requested path and instructs the model to treat it "as if it's a question." Gibberish paths get "what were you looking for?" Anything with semantic meaning gets a real answer. ## The Content Index Is Live The chat knows every page on the site — pricing, portfolio, 45+ developer tools, blog posts. The index is built at request time from the same sources the sitemap uses. Add a new page, the 404 chat knows about it on the next request. No rebuild. The AI is also forbidden from inventing URLs. Linking to a page requires calling a `search_pages` tool first, and only returned paths can be used. The page that exists *because* of broken URLs doesn't get to invent its own. ## Every Question Is a Software Project Off-topic questions — how to bake bread, how to find love, travel advice — don't get refused. They get answered through the lens of a software project. > Bread is a build pipeline. Love is shipping without user research. Travel is a migration with no rollback strategy. Delivered straight-faced. Short, dry, designed to be screenshotted. And it's a literal instruction in the system prompt, not an emergent quirk. ## Email Capture Without the Popup Every conversation with three or more messages pings my inbox — full transcript, requested path, any email shared. The lead capture is decoupled from the email field. The chat does ask for an email after a few messages, but only once. Ignore it and the conversation continues. If someone asks a Varstatt question the index can't answer, the AI says "I don't have that, drop your email and I'll get back to you." The knowledge gap becomes the capture moment. ## The Prompt Does Most of the Work The interesting part isn't the model — it's the instructions. A few lines from the system prompt that shape the whole personality: > "Think in short statements, not paragraphs. 'I don't know' is a complete sentence — no hedging, no disclaimers. Be opinionated by default." > "Give one sharp observation per answer, like a senior dev giving a code review on someone's life choices." > "Write like you're firing off a quick DM, not writing documentation. 1-3 sentences max. No bullet points. No headers. No emojis." That last block is why the chat never falls into the LLM default of structured, hedged, three-paragraph answers. The model wants to write like documentation. The prompt tells it not to. ## Why Bother The traffic was already hitting that URL. Now it does something. > Break it yourself: varstatt.com/how-to-bake-bread. --- # https://varstatt.com/jurij/p/soapnoteai-v2-now-serves-100-percent-of-users --- title: SOAPNoteAI V2 Now Serves 100% of Users url: https://varstatt.com/jurij/p/soapnoteai-v2-now-serves-100-percent-of-users author: Jurij Tokarski date: 2026-05-14 description: Eight months of building V1 and V2 side by side. The bridge mechanics, shipping continuously behind flags, and why stability — not features — gated the final cutover. section: Blog (https://varstatt.com/jurij/archive) tags: project-stories (https://varstatt.com/jurij/c/project-stories) --- SOAPNoteAI V2 now serves 100% of users. The last V1 user gets migrated the next time they log in. V1 is being formally discontinued — the old URLs still resolve for anyone with a bookmark, but it's no longer the active product. The interesting question isn't *how* we built V2. It's *why we waited*. V2 was ready for new users months ago. New signups have been getting V2 since February, and the full V2 experience since March. We could have force-migrated everyone the day parity was reached. We didn't. The gap between "V2 has every feature V1 has" and "everyone is on V2" was a deliberate choice, and the reasoning behind it is the part I want to write down. ## How It Started Last August, on a Tuesday evening, I got a DM on X from [Kunal Modi](https://x.com/kgmodi), the founder of [SOAPNoteAI](https://www.soapnoteai.com). Twenty minutes later, we had a call booked for the next morning. By the end of the next day, the paperwork was done, access was set up, and we had a brief: *better design and new features*. Not a pivot. A rebuild. One thing was clear from the first conversation: SOAPNoteAI is a HIPAA-regulated product. Every audio chunk and every note line is PHI. That shaped every decision that came after — and I'll come back to it. First commit landed five days later. Five days from "hello" to code. ## The Decision That Shaped Everything On day one of the engagement we made one call that decided how the next eight months would go: V1 and V2 would coexist until V2 was genuinely ready to take over. No race-to-replace. No big-bang cutover. No "ship the rewrite and pray". This is how I think rebuilds should work, and it's the principle behind my [app modernization package](/jurij/p/what-a-legacy-app-modernization-looks-like). The pitch is simple: you don't rebuild a working product by turning the old one off and hoping the new one is ready. You build the new one *alongside* the old one, and you let users cross the bridge when the bridge is finished. For SOAPNoteAI that meant: - **V1** kept running on its existing domain — a hand-authored static site, three years old, had paid the bills that whole time, and it worked. - **V2** lived on a separate subdomain, on a modern Next.js/React stack. - **Shared authentication** across both apps, so signing in on one side meant being signed in on the other. - **A shared backend** serving V1 web, V2 web, *and* the iOS app. Any change had to stay compatible across three clients at once. Not a "nice to have". A hard constraint. The first real piece of work after the initial commit was wiring up shared auth across V1 and V2. We didn't build a screen first. We built the bridge first. ## Business Order, Not Developer Order The developer-optimal way to do a rebuild is to mirror the old routes first — get parity, let V2 stabilise, let V1 fade. That gives you a clean diff and a tidy migration. Business context dictated otherwise. Kunal had told me a few days in: > *"One of the issues with current app is the users sign up but don't take the first action to create a note. It will be one of the first features we will be shipping."* So the first thing we built in V2 wasn't a V1 feature. It was a *new* onboarding flow that didn't exist in V1 at all. New signups landed in V2 onboarding, completed it, and got bounced back to V1 for the actual product. V2 was a thin onboarding layer over a still-V1 product. A single shared flag on the user record was the handshake — V1 read the same flag and redirected un-flagged users into V2 to complete onboarding. That set the order for everything that followed: 1. **Onboarding** — fix the activation pain first. 2. **Dashboard & notes list** — give V2 something to hold users on, but click "view" or "add note" and they still went to V1 via a specialization-to-URL map. 3. **Edit Note "v1.5"** — same features as V1, new UX. A new editing experience users could feel right away — and the foundation Penny needed. 4. **Penny** — our AI assistant for editing notes. The flagship modernization feature. The reason V2 wasn't a reskin. Built on top of the v1.5 edit surface. 5. **Patient management** — turn "patient" from a free-text field on a note into a real entity with proper IDs. Not a blocker, but a small addition that brought a new UX *and* prepared the ground for what came next. 6. **New create-note flow** — the biggest single piece of work, totally new UX, last to reach parity. Built on top of the patient entity. Each ordering was [priorities, not scope](/principles/partnership/priorities-not-scope). The founder ranked by user pain; I built top-down. There's a pattern in this order: every big feature was preceded by a smaller one that doubled as its foundation. Edit Note v1.5 became the diff surface Penny inherited. Patient management became the entity create-note needed. Two birds, one PR — and the bigger swing becomes incremental instead of monolithic. Create-note came last because it was the biggest swing and we wanted everything else stable before we touched it. ## The Bridge Between V1 and V2 The part most people miss when they hear "V2 rebuild" is that V1 had to participate. The bridge isn't a thing V2 builds in isolation — it's bidirectional code that runs in both apps and stays in sync for the life of the migration. In V2, the bridge boiled down to three pieces: - A single mapping table that translated each V2 user's context into the matching legacy page. One source of truth for "where does this user go in V1?" - A safety-valve route that punted to V1 whenever V2 native wasn't ready for that user. Always one click away. - Shared session handling so the bridge required zero changes to V1 for auth. V1 had to participate too. It checked the shared flag on the user record and redirected new users into V2 for onboarding. Its product pages added redirects into V2 for the features that had moved there. Closer to cutover, V1 picked up the migration ribbon announcing the retirement date. ## Deciding How the Migration Would Work A few weeks into the project, V2 had a dashboard and a notes list. V1 had neither — V1 sent users directly to specialization pages with no home view. So we added small UI hooks to V1: a "dashboard" button and a "my notes" button, both linking to the corresponding V2 pages. Existing users staying on V1 could now click those buttons and see V2 for those views. That was the first piece of V2 functionality existing users actually used. It also clarified what coexistence meant in practice: V2 didn't have to replace V1 wholesale — it could ship one piece at a time, with V1 linking out to it. The bigger question came up later, when Edit Note was ready. Showing existing users a different note editor wasn't a button to add to V1 — it changed the core experience. We decided it over WhatsApp: split users by signup date. A cutoff timestamp lived in the database. Users registered after that date got the new edit note experience by default. Users registered before it kept the V1 editor. That gave us a clear migration shape. New users were the V2 cohort from day one. Existing users would move when stability — not feature parity — said they were ready to. ## Shipping Continuously Behind Flags With users split between V1 and V2 by signup date, we still had to keep shipping. That meant [continuous flow](/principles/delivery/continuous-flow) — deploying to production multiple times a week, including code for features that weren't ready yet, sometimes for features that were barely started. The mechanism that made this safe was [two-layer feature gating](/principles/delivery/feature-flags). The first layer was a global flag per feature — on in dev and staging, off in production until the feature was ready. Native recording, for example, lived in production for weeks behind this flag before any user saw it. The second layer was a per-user flag, off by default. Once the global flag was on in production, this one decided who actually got the new experience. Both had to be on for a user to see a V2 native feature. That meant the users who *were* already on V2 — the new signups since February — didn't see incomplete work either. The same flags that let us roll cohorts onto new features also kept everyone else, V2 included, on stable code. ## Penny: The Reason V2 Wasn't a Reskin Around mid-December, Penny went live. Penny is an AI assistant for editing notes — a chat panel that proposes section-level changes to your SOAP note and waits for you to accept or undo each one. Two pieces of this are worth pulling out, because they speak to *why* we did the rebuild at all. **First, Penny doesn't overwrite the clinician's note.** It proposes line-level changes that the UI renders as a green-add/red-strikethrough diff. You explicitly Accept or Undo. A clinician's note is a legal document; an AI quietly rewriting it would have been disqualifying. The trust model is built into the product surface. **Second, Penny is V2-only by construction.** V1's static pages can't host a streaming chat panel with long-running connections. This is the cleanest example of why this wasn't a redesign — V2 was the platform that could host the features V1 couldn't ship. The new design was the surface; the new platform was the point. Every change Penny makes is versioned. Even after the clinician accepts an edit, the previous version is recoverable — undo isn't time-limited to the current session. That matters in a clinical context: a note that looked right last week and looks wrong now can always go back to what it was. Penny also suggests commands in the chat itself — small contextual prompts proposing the next thing the clinician might want to do with the note. "Expand the assessment." "Add a sleep history question to the HPI." "Tighten the plan." The clinician can ignore them, edit one before sending, or click through. It turns the chat from a blank-prompt experience into a guided one. ## Why We Waited By March, V2 had everything V1 had — plus Penny and structured patient management, which V1 never did. New signups were getting the full V2 experience by default. The job, by the definition most rebuild projects use, was done. [It isn't done until it works in production](/principles/delivery/production-is-done). We waited two more months. Existing users had three years of V1 muscle memory. Forcing them onto a not-yet-polished V2 would have cost more trust than any feature could buy back. The bar wasn't "V2 has every feature." It was "V2 is better than V1 in ways every user can feel, on every login." So March and April were hardening. A few examples of the work that doesn't show up in any product changelog: - **Local recovery for in-flight audio** — a refresh mid-dictation wouldn't lose a note. - **Error-reporting cleanup** — transient blips wouldn't page on-call during the cutover. - **Cohort gating** — existing users could move in batches by recency, not all at once. - **PHI hygiene** — auditing what got logged across the SOAP-note pipeline. Making sure prompts and transcripts and patient names weren't where they shouldn't be. In healthcare, the bar isn't *works.* It's *doesn't leak.* Compliance migrations don't have ribbons. They have incident reports. That's what stability gated us against. ## What V1 Taught Me About Specs With V1 still running as a living reference, whenever I wasn't sure how something should behave, I opened V1 and checked. No guesswork, no archaeological reading of old code, no asking the founder to remember. This is bigger than convenience. Software specs decay faster than software. A doc written eighteen months ago describes the product as it was when the doc was written — not as it is. The code is closer to the truth, but the code only tells you *what* happens, not *what should* happen. V1 — the live, working product — answered both at once. That mattered most for the ambiguous cases. What's the right behaviour when a user has only partially completed onboarding? What happens if you delete a note that's still being processed? V1 had answered every one of those questions in production, with real users, over three years. Whatever V1 did was, by definition, the behaviour customers had come to expect. For a rebuild, that's the most valuable spec there is. The one that's been pressure-tested by use, not just by intent. V1 was the spec for "what should this do?" right up until the day it was discontinued. ## Why Rebuilds Hide Their Cost V2 rebuilds are harder than they look, and this is the part most developers underestimate. You're not just building new features. You're building them *while keeping backward compatibility* — with the old version, with the iOS app, with the shared backend. One mistake doesn't just break V2. It breaks V1 too. The clearest example: in February, Kunal got excited about streaming transcription and shipped it in V1 first. *"Jurij — you may need to sync it"* was the message. I ported it to V2. Then V1 kept evolving while I was porting, and I had to re-port pieces I'd missed. That kind of synchronisation debt is constant in the middle of the work and invisible from the outside. Every PR in V1 is a potential change V2 needs to absorb. Every PR in V2 is a potential change the shared backend has to keep serving V1 alongside. The number of clients (V1 web, V2 web, iOS) multiplies the surface area of "don't break anything." You can plan for the features. You can't plan for the drift between two living codebases. The only thing that keeps it tractable is to ship small and ship often, so the gap between V1 and V2 never grows large enough to be unrecoverable. The bridge code lives or dies by that discipline. ## Where Design Work Is Heading [Ben Lau](https://benlau.design) was on the project before I was — designing the mobile app when Kunal brought me in for V2 web. By the time V2 needed real design attention, Ben moved across and started shaping V2's UI in Figma. The loop we ran wasn't the usual designer-handoff. Ben deliberately didn't polish Figma to 100%. He stopped around 80% fidelity — enough to communicate the structure, the hierarchy, the intent. The remaining 20% — the spacing, the micro-alignment, the hover states — wasn't where he wanted to spend Figma time. I took his designs and implemented the screens as close to them as I could, but cutting corners on the polish. Wire up the data, hit the real APIs, get the feature working end-to-end. Don't burn hours nudging a margin that the designer was going to revisit anyway. Then Ben took over. Pulled the branch, opened it in Cursor or Claude Code, and started polishing the UI — directly in the codebase. The final polish happened by editing real code with AI assistance, not by moving pixels in Figma and re-handing-off. It felt like the same shift I've noticed in development this year. AI takes more of the mechanical bits on both sides, which leaves more room for the parts that actually need a human — architecture and trade-offs on the dev side, user experience and product thinking on the design side. ## What the Cutover Actually Is The migration itself is the smallest thing in the whole story: one flag flip, and the next time the user logs in, they see V2. No batch job, no notification storm. The whole technical apparatus — the shared auth, the V1 routing map, the safety-valve route, the feature flags, the bidirectional bridge — existed to make that one toggle safe. V1 is formally discontinued today — bookmarks still resolve, but the active product is V2. Eight months of work that lived in the seams between two apps quietly becomes invisible. A working product paid SOAPNoteAI's bills for three years. Today it hands off to its successor without anyone losing a note. That's the whole job. > Live: [soapnoteai.com](https://www.soapnoteai.com). --- # https://varstatt.com/jurij/p/what-ai-automation-retainer-looks-like --- title: What an AI & Automation Retainer Looks Like url: https://varstatt.com/jurij/p/what-ai-automation-retainer-looks-like author: Jurij Tokarski date: 2026-05-10 description: AI automation services on a weekly retainer — personal AI assistant setup, workflow scripts, ongoing maintenance. Same retainer, same person, same cadence as building. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Most people who want AI automation services don't want a course. They want the thing running. Someone competent picks the tools, builds the personal AI assistant, wires it to their inbox and calendar and bank, ships the small automations that take 80% of their operational routine off the table, and stays around to keep it working as their tools and life change. That's what this retainer is. Setup is week one. Maintenance and new automations are everything after. ## The Setup Isn't the Hard Part The hard part is **knowing what to automate, what to leave manual, and what to delete entirely**. The same lesson the [automation audit](/jurij/p/what-an-automation-audit-looks-like) lands on a business retainer applies here: most "I should automate this" instincts are wrong. Some workflows are too low-volume to earn their maintenance cost. Some need human judgement at the last 20% and a [human-in-the-loop pattern](/jurij/p/workflow-automation-is-business-superpower) is the right shape. Some don't need to exist at all. Setup work is roughly: pick the assistant model and harness, wire up the accounts that matter, build the AI integration services into the tools they already use, write the first few scripts that handle the actual repetitive parts of someone's week — invoice routing, lead reminders, a daily summary of what happened across their tools, a reply-drafter for the inbox patterns they hate. By the end of the first couple of weeks the system is running and the person can use it without thinking about it. The setup ends. The retainer keeps going. ## What the Ongoing AI Automation Work Looks Like A typical week, in no particular priority order: **New automations.** The list grows. Once the first three scripts are running, the fourth and fifth become obvious. Someone notices they keep doing the same calendar dance, or the same Notion template fill, or the same export-and-email step on Friday. That goes on the board. By month three, most of the operational routine runs through workflow automation services that didn't exist when the retainer started. **Tool drift.** Anthropic ships a new model, the assistant harness gets a new feature, an MCP server breaks against a vendor change, a script depends on a CLI that just changed its flag. [Done weekly, this is small](/jurij/p/why-weekly-retainers-work-better-than-sprints). Done annually, it's a weekend of catch-up. **Security hygiene.** API keys rotated. Secrets out of files and into a manager. Credentials scoped to what they need. Logging that doesn't leak. The kind of thing that's easy when someone's paid to think about it once a week, and that nobody does on their own. **Prompt and context tuning.** What the assistant remembers, what it forgets, what context gets injected, where the personalization lives. The setup that worked at month one needs adjusting at month three because the person's work shifted and the automations should follow. **Bug fixes and edge cases.** A script that worked for 90% of inputs hits the 10% case. The webhook that fired correctly for six months silently breaks because a vendor changed a payload format. Caught early through monitoring, fixed the same week. **New tools.** A new AI product launches, an existing tool adds an API, a workflow can now be done end-to-end where last month it couldn't. Worth integrating? Worth waiting? Same judgement someone applies to their own stack — applied weekly, by someone whose job it is. ## Who This Fits Solo operators and creators who run their own business and want what a small operations team would give a bigger company — without hiring an AI consultant on a per-hour rate or staffing a full role. Consultants whose calendar and inbox are the product. Course-runners who automated the obvious things and hit the wall on the next layer. Founders who use AI heavily but don't want to also become the person maintaining their own stack. The pattern across all of these: **the work is too varied for an off-the-shelf product, but too personal to delegate to a team**. One competent person, weekly, is the right shape. ## What This Isn't Not a course. Not an automation consultant who advises but doesn't ship. Not a workshop where someone teaches and the client implements. The client doesn't implement. The client asks for things and the things get built. The same retainer that builds also maintains, the same way [the maintenance retainer](/jurij/p/what-a-software-maintenance-retainer-looks-like) is structured — one person, one cadence, no handoff. If someone wants to learn to do this themselves, this isn't the right engagement. There are good courses for that path. This retainer is for people who want the outcome, not the skill. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What an Automation Audit Looks Like](/jurij/p/what-an-automation-audit-looks-like)** — start here when you have an existing stack and need an honest read before deciding what to fix - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — for products with users, not personal stacks - **[What a Fractional CTO Engagement Looks Like](/jurij/p/what-a-fractional-cto-engagement-looks-like)** — when the AI work is part of building a company, not running a person - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/what-whitelabel-dev-retainer-looks-like --- title: What a White Label Web Development Retainer Looks Like url: https://varstatt.com/jurij/p/what-whitelabel-dev-retainer-looks-like author: Jurij Tokarski date: 2026-05-05 description: White label web development on a weekly retainer — senior dev capacity behind an agency or design studio's brand, invisible to their clients. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Most agencies and design studios reach a point where they can sell white label web development to their clients but can't deliver it cleanly. They close clients who want websites, apps, custom integrations — and then either subcontract chaotically, hire too early, or pass on the work and lose the deal. This retainer is the third option. The agency keeps the client relationship, keeps the brand, keeps the margin they want to set. I deliver the work behind their name. Their pipeline becomes the queue. ## Same Retainer, Partner-Facing The white label version of the [Varstatt weekly retainer](/) is the same retainer as every other shape: $997/week, cancel anytime through Stripe, senior engineer working week after week, no minimum commitment. The "white label" framing describes the buyer — a white label agency, design studio, or productized-service operator — and the fact that the work ships under their brand instead of mine. The partner subscribes once. Their clients' projects queue against the retainer. When the pipeline runs full, the retainer runs hot. When it runs dry for a week, they pause through Stripe and resume when the next project lands. No per-project quotes between us, no scope renegotiation per site, no overhead on every deal they close. What they tell their client about pricing, scope, and timelines is **their business**. My rate is fixed and stable. Their margin is whatever they decide to set on top. ## What the Work Actually Looks Like The work itself is the same work the retainer always does — just routed through a partner. A few common shapes that show up in the queue: **Marketing and brochure sites.** White label web design services on top of WordPress, Squarespace, Webflow, or simple Next.js. Themes built from scratch or rebuilt, custom design implementations, design handoffs from a partner's in-house team. Roughly one site per week is realistic if scopes are tight. Two sites per week stretches it. Four sites per month is a reasonable steady state for an agency feeding the queue. **Client web apps.** Booking flows, internal portals, member areas, dashboards — the white label app development side of the queue. The same capacity that would build a [PoC in 2 weeks](/jurij/p/what-a-2-week-poc-looks-like) or an [MVP in 6 weeks](/jurij/p/what-a-6-week-mvp-build-looks-like) — except the partner is the one scoping with the client. **Integrations and small projects.** Stripe wiring, custom forms with backend logic, third-party API hookups, email automation that exceeds what the no-code tool can do. The work agencies usually subcontract poorly because individual jobs are too small to interview a contractor for. **Maintenance on sites the agency built.** A partner's existing client portfolio that needs care — same work as [the maintenance shape](/jurij/p/what-a-software-maintenance-retainer-looks-like), routed through the partner instead of direct. The queue isn't fixed. The retainer doesn't care whether week three is one big project or five small ones — the cadence is what's billable, not the count of deliverables. ## How Invisibility Works I don't talk to the client. The partner runs the brief, the meetings, the revisions, the invoices. I deliver the work into a repo or platform they own, under their account, with their credentials. No Varstatt branding on commits, no portfolio cross-posts without their permission, no public mentions of the projects. This isn't a legal or NDA position — it's the operating default. The partner's relationship with their client is the asset. My job is to make that relationship work, not to compete with it. If a partner wants the opposite — co-branded work, joint case studies, public credit — that's a different shape and a different conversation. Most don't want it, which is why this retainer exists. ## How White Label Web Development Differs from Subcontracting Most agencies who try development work end up subcontracting per project. They quote, they win, they scramble to find someone available, they negotiate per-job rates, they manage scope creep across freelancers who don't share context between projects. It's chaotic for them and unstable for the contractor. The white label web development retainer fixes both ends. **For the partner, it's predictable capacity** — a known person on a known cadence at a known rate, who already knows their client's previous projects. **For me, it's stable work** — one buyer relationship, one rate, no per-project sales cycle, the same continuity benefit [the solo model](/principles/philosophy/solo-model) gives every retainer. Per-project freelance economics work for one-offs. They don't work for an ongoing partnership where four to eight projects flow through a year. The retainer model handles that volume the way it's meant to. ## Who This Fits Marketing and ads agencies whose clients keep asking for sites or apps and who don't want to staff a dev team. Design studios delivering Figma files who need a white label website developer downstream to ship the build. Productized-service operators in adjacent niches — SEO, branding, conversion optimisation — whose clients keep needing white label web development services the operator can't do themselves. Communities of designers or no-code builders who hit the limits of their toolkit and need a senior dev on call for the projects that don't fit. The pattern across all of these: **the partner has the audience and the close, but doesn't have the build capacity**. One competent person, weekly, is the missing piece. ## What This Isn't Not a referral program. Not an affiliate split. Not a one-off subcontract per project. Not a price-per-site contract. The partner subscribes to the retainer like any other client. The white-label framing is the operating reality of the relationship, not a different commercial structure. If a partner wants per-project pricing or one-off work, that's a different shape — closer to [the PoC](/jurij/p/what-a-2-week-poc-looks-like) or [MVP build](/jurij/p/what-a-6-week-mvp-build-looks-like) shapes for fixed-scope projects, with the white-label terms layered on top. The retainer is for ongoing flow. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a 2-Week PoC Looks Like](/jurij/p/what-a-2-week-poc-looks-like)** — when a partner has a single fixed-scope project, not ongoing flow - **[What a 6-Week MVP Build Looks Like](/jurij/p/what-a-6-week-mvp-build-looks-like)** — when a partner's client needs a real first version, not a marketing site - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — for the partner's existing client portfolio that needs care - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope, working pattern, and how invisibility maps to your client relationships. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/errors-that-never-left-the-device --- title: Errors That Never Left The Device url: https://varstatt.com/jurij/p/errors-that-never-left-the-device author: Jurij Tokarski date: 2026-05-03 description: Eleven silent failure paths in a clinical iOS app. None of them surfaced as a backend alert. section: Blog (https://varstatt.com/jurij/archive) tags: software-design (https://varstatt.com/jurij/c/software-design), debugging (https://varstatt.com/jurij/c/debugging) --- ## The note that vanished A clinician recorded an audio note. The app said it uploaded. The note wasn't there the next morning. That's how the audit started. No crash report, no stack trace — a support ticket and a missing document. The clinician was certain they'd recorded it. The UI had shown the completion animation. The backend had no record of it. I pulled the relevant service and started reading `AudioUploadService.verifyServerCompletion()`. After a successful upload, the app calls a status endpoint to confirm the backend processed the note — defense-in-depth, the kind of thing you'd add after a prior incident. The function calls the endpoint in a retry loop. Five attempts. If all five fail, it reaches this block: ```swift } else { // Fallback: If status check fails repeatedly, assume completion after reasonable time Logger.audio("⚠️ Status check failed, but assuming completion for document: \(documentId)") let finalResult = UploadResult(success: true, documentId: documentId, error: nil) self.delegate?.uploadStageChanged(.completed) self.delegate?.uploadCompleted(finalResult) completion(finalResult) self.notificationService.showUploadCompletionNotification(documentId: documentId) } ``` `success: true`. Completion notification fired. The UI animates closed. The comment says "handles cases where the server returns empty responses but the upload was successful." What it doesn't say is that if the server is unreachable — overloaded, degraded, briefly down — there is no way to distinguish that from a transient empty response. The function guesses success, marks the document done, and exits. No retry scheduled. No error state. No signal leaving the device. The distinction between "upload succeeded, verification failed" and "upload is gone" is permanently lost. The clinician who filed the ticket couldn't have known this was happening. The app was designed to tell them it worked, and nothing left the device to say otherwise. ## How "assume success" became the house style Once I found the verification fallback, I went looking for the same shape elsewhere. Twenty minutes, nine more. `AudioRecorderViewModel.startRecording()` sets `isRecording = true` and calls `delegate?.recordingStateChanged(.recording)` early in the function — before validation. Then, if engine initialization fails: ```swift guard let audioEngine = audioEngine else { Logger.debug("❌ Failed to create audio engine") return // Early return — isRecording still true } ``` The function returns. `isRecording` is still `true`. The UI shows a recording in progress. Nothing is being recorded. The AVAudioSession configuration earlier in the same function can also fail silently — catches, logs to debug, continues. Audio file creation can fail and the function continues. Four failure paths in one function, each one moving on without rollback. `deleteNote` has a variant: ```swift do { try FileManager.default.removeItem(at: audioFilePath) } catch { Logger.warning("Failed to delete offline audio file: \(error)") // Continue anyway — no return, no error thrown } do { try FileManager.default.removeItem(at: jsonFilePath) } catch { Logger.error("Error deleting note: \(error)") } ``` The audio file deletion throws. The function logs and keeps going. The metadata deletion runs. Now the audio file exists on disk with no metadata pointing to it — an orphan that nothing will clean up. From the outside, the delete succeeded. Each of these is defensible in isolation. Don't block the entire recording flow because the audio session threw. Don't fail the whole delete because one file had a permissions issue. The problem isn't any single decision — it's that the same decision, **the device decides what counts as success**, was made repeatedly until it became the default. ## Logged locally, reported nowhere Here's the part that ties the verification fallback, the state leak, and the orphaned files together: none of them left the device. The codebase had eight-plus critical `catch` blocks. Every one called `Logger.debug`, `Logger.warning`, or `Logger.error`. None of them called out. No endpoint. No signal to the backend. No correlation ID support could use to pull a backend log. The web version of this product already had a `/feedbackV2` endpoint — auth-gated, accepts errors, stores them for support triage. iOS never wired it up. Crash reporters like Sentry or Crashlytics don't help here. They catch uncaught exceptions and fatal crashes. Every error in this codebase was a handled error. From a crash reporter's perspective, the app was healthy. All those `catch` blocks were doing their job — the errors were caught, they weren't going anywhere. When a support ticket came in — "my note disappeared," "the recording seemed to stop" — the support team had nothing. No timestamp. No error code. No HTTP status. No indication of which of the eight failure paths had triggered. They could ask the clinician to reproduce it with a device attached to Xcode (not realistic), or guess. A complete logging setup and an absent reporting setup feel like the same thing when you're writing `Logger.error(...)`. They're not. ## The same bug at the configuration layer Halfway through the audit I hit something that wasn't about error handling at all — same failure mode, different layer. In `Constants.swift`, around line 322: ```swift // #if DEBUG // return .dev // #elseif PREPROD // return .preprod // #else // return .prod // #endif AppEnvironment.current = return .dev ``` The conditional compilation guards were commented out. Every build — debug, TestFlight, App Store release — unconditionally set the environment to `.dev`. Production clinician traffic had been routed to the development API. Consequences beyond missing notes. PHI in a HIPAA-adjacent product, routed to the development environment, means audit segregation is broken. Production data contaminating dev logs. The infrastructure isolation compliance depends on isn't there. The compiler had no objection. Tests passed — they ran against the dev environment too. The app functioned normally because there was nothing to notice. The underlying problem is a property of Swift compiler directives. `#if DEBUG` is compile-time, not runtime. When it's commented out, it's not disabled — it's erased: ```swift // This is a runtime conditional — commenting it out is a syntax error if DEBUG { } // This is compile-time — commenting it out silently removes the branching // #if DEBUG // #endif ``` A commented-out `if` is broken code that fails to compile. A commented-out `#if` is syntactically valid code that runs the fallback unconditionally. The distinction is invisible in code review. **Commented code looks intentional.** Reviewers assume it was deliberate. Nobody asks why `#if DEBUG` is wrapped in comments. ## Wiring the endpoint without leaking PHI The fix for the missing reporting is not "send more to the server." In a HIPAA-adjacent product, that sentence is where you stop and think. The first instinct in Swift is to include `error.localizedDescription`. Don't. OS-generated error strings drag in context you don't control — file paths, error chains, localized strings from third-party libraries, any of which can contain document names, template names, or identifiers with patient context: ```swift // UNSAFE — never do this: report(code: .uploadFailed, context: ["error": error.localizedDescription]) ``` The sanitizer that ended up in `CriticalErrorReporter` is strict by design: ```swift private func sanitizeContext(_ context: [String: Any]) -> [String: Any] { var safe = [String: Any]() for (key, value) in context { guard key.range(of: "^[a-zA-Z0-9_]+$", options: .regularExpression) != nil else { continue } switch value { case let bool as Bool: safe[key] = bool case let int as Int where int >= -1_000_000 && int <= 1_000_000: safe[key] = int case let string as String: if string.range(of: "^[a-zA-Z0-9_:-]{1,48}$", options: .regularExpression) != nil { safe[key] = string } default: continue } } return safe } ``` Primitives only. Strings must match a tight alphanumeric pattern under 48 characters. That allows `"5xx"`, `"http_401"`, `"attempt_3"`. It blocks `"HTTP/1.1 401 Unauthorized"`, anything with spaces or slashes, anything that could encode an identifier. The error codes themselves are stable enums, not free-text strings. `.falseCompletionAssumed`, `.audioSessionConfigFailed`, `.uploadFailed`. A code the support team can look up in a table, not a prose description that might contain whatever was in memory when the error fired. `documentId` is safe to include — the endpoint is auth-gated, the document ID is the same UUID the user already sees in their note list, and support needs it to correlate a client error with backend logs. ## The cross-platform contract you didn't know you signed When I wired up the iOS reporter, the first pass used a pipe-delimited comment for the body: ```swift let comment = "audio_error:code|\(code)|ctx:\(context)|doc:\(documentId)|platform:ios" let body = ["type": "audio_error", "comment": comment] ``` Looked reasonable. The endpoint accepted it — 200 OK. Test passed. Then I cross-referenced the actual merged web code: ```javascript const body = { feedback: false, comment: JSON.stringify({ code, platform, context }), type: "audio_error", document_id, template_type, user_agent }; ``` Different body shape entirely. `document_id` at the top level, not inside the comment. `comment` as a JSON string, not pipe-delimited. The analytics consumer downstream was parsing `document_id` from the top-level field — the iOS version was sending it inside the comment string, where the parser wasn't looking. Both versions compiled. Both sent 200 OK. Both "worked" locally. The iOS version would have created malformed records: missing `document_id` in the analytics table, broken parsing, phantom errors with no associated document. This is the kind of bug that doesn't fail locally because each platform is tested against its own understanding of the contract, not the other platform's implementation. No crashes. Quietly wrong data — missing fields in dashboards, broken analytics joins, records that look valid but don't correlate with anything. The only way I found it was by reading the actual web code. The spec said "send error data to `/feedbackV2`." The spec didn't define the exact body shape. I assumed I understood it. I was wrong about one field's location and the entire encoding. **In a multi-platform product, the primary platform's merged code is the contract.** Not the docs, not a conversation from six months ago. The code. ## Don't replace silence with noise Once the reporter existed, the next problem was obvious. `processAudioBuffer()` runs per audio frame. A 2-minute recording generates thousands of frames. If there's a persistent write failure, every frame fires the catch block. Without dedup, that's potentially 7,000+ error reports from one bad recording session. Support inbox floods. DynamoDB bill climbs. Signal becomes useless — you can't tell if this was one bad recording or seven thousand separate users. A per-session Set in the reporter: ```swift private var reportedCodes = Set() func report(code: MobileErrorCode, ...) { guard !reportedCodes.contains(code.rawValue) else { return } reportedCodes.insert(code.rawValue) // Send the report } ``` First occurrence sent. Every subsequent occurrence in the same session silently dropped. Support gets one record that the buffer write failed — the signal they need. The set resets on app restart. If the bug is persistent, you get one report per session — the right signal for systematic vs. transient failures. A `bypassDedup` flag covers the cases where one code needs to fire even if a related one already did — the false-completion path uses it, because `.falseCompletionAssumed` is a distinct failure mode from `.uploadFailed` even when both fire in the same session. Reports that fail to send queue in memory, capped at 20, and flush when the network returns. That matters because the most common time to see these errors is during degraded network conditions — the same conditions that would drop the report itself. ## What it added up to Eleven bugs by the count: the verification fallback assuming success, four silent paths in `startRecording`, two in `deleteNote`, eight-plus `catch` blocks with no outbound signal, the commented-out environment guards, and the cross-platform contract mismatch. Some are variants of the same thing depending on how you count. They weren't eleven independent bugs. They were one decision — or rather the absence of one — repeated across the codebase. Nobody sat down and decided "let's make sure errors don't reach the backend." The web app had an answer: `/feedbackV2`. The iOS port never carried it across. Without it, every `catch` block defaulted to device-only logging, not because anyone chose that, but because there was nothing else to reach for. A `catch` block without an outbound signal is a feature flag set to "silent." Every time you write one, you're making a product decision — that this failure mode is something support doesn't need to know about, that users affected by it will either not notice or won't need help. Sometimes that's the right call. It should be a call, not a default. Eleven path-of-least-resistance decisions, one structural gap underneath them. --- # https://varstatt.com/jurij/p/works-locally-fails-after-deployment --- title: Works Locally, Fails After Deployment url: https://varstatt.com/jurij/p/works-locally-fails-after-deployment author: Jurij Tokarski date: 2026-04-30 description: File tracing misses runtime paths, Cloud Run changes the working directory, CloudFront eats routes, and OG images silently vanish — four failures that pass every local test. section: Blog (https://varstatt.com/jurij/archive) tags: debugging (https://varstatt.com/jurij/c/debugging), software-delivery (https://varstatt.com/jurij/c/software-delivery) --- [The Production Bugs That Never Threw an Error](/jurij/p/production-bugs-that-never-threw-an-error) was about systems that reported success while running the wrong thing. [200 OK, Data Wrong](/jurij/p/200-ok-data-wrong) was about APIs that returned clean responses with incorrect output. These four are different — the code was correct, the build passed, but the deploy target had constraints that only surfaced in production. ## The File That Wasn't Bundled Local dev worked. Preview builds worked. The production deployment threw a runtime error trying to read a file that was clearly in the repository. Vercel's build system uses output file tracing to determine which files a deployment actually needs. When the path to a file is computed at runtime — built from variables, not a literal string — the tracer can't detect the dependency. The file gets left out of the deployment bundle. ```js // next.config.js experimental: { outputFileTracingIncludes: { '/api/report': ['./data/templates/**/*'], }, } ``` Any file read with a computed path needs to be declared here. The tracer won't find it on its own. ## The Path That Moved A Cloud Run service authenticated with a service account JSON file. Locally I used a relative path — `./service-account.json` — and it resolved fine because the working directory was my project root. In Cloud Run, if the Dockerfile doesn't set `WORKDIR`, the container starts in `/`. The relative path resolved to somewhere that didn't exist, and the error came back as a generic authentication failure rather than a file-not-found. ```ts import path from 'path'; const keyFile = path.resolve(__dirname, 'service-account.json'); ``` `__dirname` is the directory of the compiled file, which is stable regardless of where the process is invoked from. ## The Routes That Vanished A CDK stack had a CloudFront distribution with behaviors for several path patterns. `/api/*` was supposed to route to a Lambda. `/*` was a fallback to S3. After a deploy, API routes started returning S3 content instead of Lambda responses. CloudFront applies behaviors in the order they're defined in the array. `/*` had been added first, and it matched everything — including `/api/*` — before the more specific pattern had a chance to evaluate. CDK doesn't validate or sort behavior order. Specific patterns first, catch-all last. ## The OG Image That Disappeared Three bugs stacked on one OG image. First: I was rendering at 1200x600. Facebook needs 1200x630 for a full-size preview — anything smaller renders as a tiny thumbnail or gets skipped entirely. Neither platform logs an error. I discovered it by running the URL through Twitter's Card Validator after a campaign showed zero image previews. Second: the image used CSS Grid layout, `clip-path`, and `backdrop-filter`. In the browser it looked right. Satori — the library Next.js uses to render `opengraph-image.tsx` server-side — only supports a subset of CSS. No `display: grid`. No pseudo-elements. No `clip-path`. No `backdrop-filter`. When it encounters something it doesn't support it doesn't throw — it silently skips the rule or collapses the element. Third: after fixing dimensions and CSS, I redeployed. Twitter still showed the old version. Social platforms cache OG tags aggressively — Twitter for up to a week, Facebook for days. Redeploying does nothing. The only way to force a re-fetch is through each platform's debug tool. ## What They Share Local dev doesn't have a file tracer. It doesn't change the working directory between builds. It doesn't apply CDN behavior ordering. It doesn't validate OG dimensions or enforce a CSS subset. Every one of these bugs lived in the gap between what local dev simulates and what production actually enforces. The build passing tells you the code compiles. It tells you nothing about whether the deploy target will accept it. --- # https://varstatt.com/jurij/p/when-llm-remembers-too-much --- title: When the LLM Remembers Too Much url: https://varstatt.com/jurij/p/when-llm-remembers-too-much author: Jurij Tokarski date: 2026-04-28 description: A vendor rate becomes a budget constraint and reasoning leaks across step boundaries — two failures from implicit state crossing where it shouldn't, and the architecture that fixed both. section: Blog (https://varstatt.com/jurij/archive) tags: ai (https://varstatt.com/jurij/c/ai), software-design (https://varstatt.com/jurij/c/software-design) --- Multi-step AI pipelines break in production the same way every time. The output looks wrong — stale reasoning, invented constraints, downstream logic that doesn't add up. It looks like a bug in the later step. It's not. The model retained something from a prior step that it shouldn't have. ## The Budget That Wasn't A multi-step pipeline walks users through sequential analysis stages. [Market sizing](/discovery/market-sizing) feeds [competitive analysis](/discovery/competitive-analysis), which feeds the tech stack recommendation, which feeds the [cost estimate](/discovery/build-cost-plan). Ten steps, each reading prior outputs. One step produced a vendor rate of **$997/week** as a reference point for agency development costs. Three steps later, the tech stack recommendation cited "$997/week" as the user's budget. The model had reinterpreted a comparison rate as a constraint. The prior step outputs were injected as raw text: ```xml {{costCalculatorOutput}} ``` The model had no way to distinguish "this is a vendor rate for comparison" from "this is what the user can spend." The fix was scope guards — labeling what context represents, and what the model should not do with it: ```xml This output contains vendor rate comparisons used for cost estimation. These are NOT the user's budget. Do not use these figures as budget constraints in the tech stack recommendation. {{costCalculatorOutput}} ``` ## The Reasoning That Leaked An eight-step pipeline ran sequential analysis stages — opportunity analysis, SWOT, bid strategy, and so on. Each step consumed the prior step's output via `previous_response_id`. The first symptom was token creep. Responses were getting longer and slower. When I traced what the model was reasoning about in step three, it was hauling in thinking from step one — competitor analysis referencing initial opportunity framing that was no longer relevant. The chain didn't carry forward selected outputs. It carried everything: the model's reasoning, intermediate tool calls, abandoned lines of thought. The fix was a clean break at every step transition: ```typescript function isSyntheticThreadId(threadId: string): boolean { return threadId.startsWith("synthetic_"); } if (source === RunSource.NEXT_STEP) { threadId = buildSyntheticThreadId(sessionId); message = buildStepStartContext(artifacts, recentMessages) + "\n\n---\n\n" + userInput; } await llm.streamOrchestrator({ threadId, previousResponseId: isSyntheticThreadId(threadId) ? undefined : threadId, message, }); ``` At step boundaries, the thread ID resets to a synthetic value. The driver checks `isSyntheticThreadId()` before setting `previous_response_id` — if synthetic, the call is fully stateless. Context comes from a structured text block built from the current state, not from the chain's accumulated history. ## The Fix That Worked Everywhere Every failure had the same shape: implicit state — whether it was unscoped context or a chained conversation history — created a contract the code assumed would hold. The model remembered too much, and the wrong things. The fix was the same each time. **Stateless at step boundaries, chained within.** ```typescript function buildStepStartContext(artifacts: Artifact[], messages: ChatMessage[]): string { const lines: string[] = []; lines.push("[Previous artifacts — current versions including user edits]"); for (const artifact of artifacts) { lines.push(``); lines.push(artifact.content); lines.push(``); } lines.push("[Recent conversation — last 30 messages]"); for (const msg of messages.slice(-30)) { lines.push(`${msg.role}: ${msg.message}`); } return lines.join("\n"); } ``` At every step transition: reset the thread ID, fetch the current state of all artifacts from the database, inject that state as a structured text block with semantic labels. Within a step — retries, follow-ups, agent callbacks — chain normally for conversational continuity. Each step becomes independently resumable. Contaminated reasoning is gone. A comparison rate stays a comparison rate because the label says so. The cost surprised me. Stateless reconstruction loses the cached token discount from chaining. Across a full eight-step workflow, it was about 27% cheaper anyway. Early steps have little to cache. Later steps were paying to cache stale content the user had already edited. Chain for conversational depth within a step. Break the chain and inject labeled context at every step boundary. --- # https://varstatt.com/jurij/p/what-a-firebase-audit-looks-like --- title: What a Firebase Audit Looks Like url: https://varstatt.com/jurij/p/what-a-firebase-audit-looks-like author: Jurij Tokarski date: 2026-04-28 description: Firebase consulting, structured as a one-week audit. Firestore queries, security rules, cost drivers — find what's exposing data and what's running up the bill. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Most Firebase bills don't spike because of a traffic surge. They spike because of a [data model that made sense for 100 users and quietly becomes expensive](/principles/philosophy/business-cost) at 10,000. By the time the Google invoice lands, the problem has been compounding for months. A **Firebase audit** is one week of structured review: Firestore data model, security rules, Cloud Functions, auth setup, and billing. $997. One week, one deliverable. Most Firebase consultant engagements are open-ended — this one isn't, by design. ## Firebase Isn't the Best Platform. It's the One That Lets One Person Ship. Firebase isn't theoretically superior to its competitors. Postgres is a better database. AWS is more flexible. Supabase has a more sensible API for a lot of operations. Plenty of teams have good reasons to be on something else. I'm on Firebase anyway. It's the only platform I've used where a single developer can ship a complete web app — auth, hosting, real-time database, file storage, serverless backend, push notifications — without standing up infrastructure for any of it. No DevOps role to fill. No Kubernetes. The platform makes a deliberate trade: [less control in exchange for less surface area](/principles/philosophy/solo-model) to manage. For a one-person studio, that trade pays off every week. The downside is that Firebase is **easy until it isn't**. The defaults the docs teach — [denormalized documents, listeners on entire collections](/jurij/p/fidder-overengineering-made-me-pay), Cloud Functions triggered on every write, security rules from the quickstart — work fine for the first few hundred users. They start failing in specific, expensive ways once the product gets traction. Reads scale linearly with users; cost scales superlinearly because of the patterns the platform encourages. This audit exists because I've already paid that tuition. Every Firebase project I've shipped has taught me a specific way the platform punishes naive use: a denormalized document that blew the read budget when one collection grew faster than expected, listeners on a parent collection that re-fetched on every child write, a Cloud Function chain where one trigger spawned three more, security rules that worked at launch and quietly failed when a new role was added. These aren't theoretical failure modes. They're scars I have, plus the fixes that worked. You can pay the same tuition I did — six months of debugging cost spikes, six weeks refactoring data models, weekend incidents where rules let too much through. Or you can pay $997 for a week of someone walking your codebase with a checklist built from those exact failures. You're borrowing scars I already earned. If your bottleneck is "should we be on Firebase at all?" — the audit answers that too. Sometimes the right call is to migrate to Postgres or Supabase. I'll tell you if that's true. ## The Problem Is Usually Structural Firestore performance issues come from data models built around how the app looks, not how it queries. When a product is moving fast, the instinct is to match the data shape to the UI. A user profile document stores everything — arrays that grow without bound, nested objects, fields that only make sense in one screen. Works until the queries catch up. **Reads are the main cost driver.** A listener returning 500 documents on every page load, a Cloud Function triggering on every write to process three fields — these aren't bugs, they're design decisions that compound. I've seen a single restructure cut read counts by 80%. Moving computed values out of Cloud Functions has eliminated thousands of daily invocations on more than one project. The audit maps every critical query path: collection structure, composite indexes, listener efficiency, Cloud Functions triggers. The data model and query logic are tightly coupled — fixing one without reviewing the other misses half the picture. That's true whether you're dealing with [Firestore transaction edge cases](/jurij/p/using-firestore-transactions-to-handle-race-conditions) or [cross-field listener patterns](/jurij/p/merging-two-firestore-listeners-for-cross-field-or-queries). ## Security Rules Are Server-Side Access Control If your Firestore security rules are wrong, it doesn't matter what the client code does — the data is exposed. The most common pattern: rules copied from the docs during setup, updated inconsistently as the app grew. A collection added six months ago has `allow read, write: if true;` because it was faster to unblock a build than to write proper rules. Every collection gets reviewed — rule coverage, auth flow integrity, write validation, and how Admin SDK usage in Cloud Functions interacts with client-side rules. The question is whether a malicious client can read or write data they shouldn't. In most projects I audit, the answer is yes for at least one collection. This is the same class of problem as [when polish over security costs real](/jurij/p/when-polish-over-security-costs-real). ## What the Week Covers Day one covers billing: Cloud Functions invocation counts, Firestore read/write/delete breakdowns, storage access patterns. Days two and three cover the data model and queries — collection structure, indexes, read paths for the most-used features. Days four and five cover security rules and auth. The output is a written report: what I found, what it costs, what to fix first. Most projects have two or three changes that account for most of the impact. ## Who Books This [The clearest signal is a bill that moved in a direction you didn't expect](/principles/diligence/monitor-day-one). The second is an email from Google flagging insecure rules — automated, worth taking seriously. The third is a traffic event where unexpected usage made the cost and performance implications visible at the same time. If you're considering migrating off Firebase because of cost or complexity, the audit is a useful first step. Sometimes the data model just needs to change. Sometimes Firebase isn't the right fit, and the audit makes that case clearly. If your Firebase bill is moving in the wrong direction, this is the audit. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a Code Audit Looks Like](/jurij/p/what-a-code-audit-looks-like)** — broader scan beyond Firebase: architecture, security, performance, tech debt - **[What a DevOps Audit Looks Like](/jurij/p/what-a-devops-audit-looks-like)** — for the deploy pipeline and hosting configuration around the Firebase app - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — once the audit fixes land, keep the bill and security rules from regressing - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/what-a-software-maintenance-retainer-looks-like --- title: What a Software Maintenance Retainer Looks Like url: https://varstatt.com/jurij/p/what-a-software-maintenance-retainer-looks-like author: Jurij Tokarski date: 2026-04-26 description: Software maintenance services on a weekly retainer: dependencies, security, performance, monitoring, bug fixes, and new features — same diligence as building. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Most of the industry treats software maintenance as a separate phase from building. Different team, different rate, different mindset. The handbook here disagrees: [development and maintenance aren't separate](/principles/diligence/no-split). They're the same work, applied to a product at different points in its life. So a maintenance retainer at Varstatt is the same retainer as a build retainer. Same person, same $997/week, same task board, same cadence. What changes is the starting point: the product is already running. The work blends keeping it healthy with shipping new things on top of it — both in the same engagement, against the priorities you set week by week. ## Maintenance Is Diligence Applied to a Live Product When I build something new, I'm running a set of [diligence](/principles) practices in parallel with shipping. [Monitor from day one](/principles/diligence/monitor-day-one) so problems surface before users notice. [Observe and improve](/principles/diligence/observe-improve) so the next version is informed by real usage. [Documentation emerges](/principles/diligence/documentation) from the work, not as a separate task. [Incident response](/principles/diligence/incident-response) when something breaks — stop the bleeding first, then root-cause. A maintenance retainer is those same practices, pointed at a product that's already running. The build phase is over; the diligence phase continues. Dependencies still need updating. Security still drifts. Performance still degrades as data grows. Third-party APIs still ship breaking changes. Bugs still surface at edges that didn't exist at launch scale. Someone with the right judgement still needs to be looking. The work isn't smaller than building — it's the same kind of work, just less of it per week, and the cadence is set by what the live product is asking for instead of what's on a roadmap. ## Onboarding Onto Software I Didn't Build Most maintenance retainer requests start the same way: "we have something running, the original developer is gone or moved on, can you take it over?" Yes. Onboarding onto an inherited codebase is part of the work. Week one is reading the code, mapping the data model, identifying what's load-bearing, finding the parts the original team understood and the new team will trip over. Production credentials, deploy pipeline access, monitoring dashboards, third-party accounts — handed over deliberately, with [client owns everything](/principles/philosophy/client-owns-everything) as the baseline. By the end of the first couple weeks, the codebase is in my head deeply enough to make changes safely. Dependencies are current. Monitoring is honest. The first round of "this looks fragile" issues is either fixed or queued. From there, the steady-state retainer rhythm starts. If the codebase needs more than care — if it's structurally past its useful life — that surfaces in onboarding, and the [legacy modernization](/jurij/p/what-a-legacy-app-modernization-looks-like) shape is the right next step instead. The maintenance retainer doesn't pretend a rewrite isn't needed when one is. ## What the Work Actually Looks Like A typical week, in no particular priority order: **Dependencies.** Node, framework, library, transitive package updates. [Done weekly, this is 30 minutes and a deploy. Done annually, it's a two-week migration project](/jurij/p/why-weekly-retainers-work-better-than-sprints). **Security.** CVE feeds, Dependabot alerts, npm audit. Triaged, applied, redeployed. Vulnerabilities don't accumulate. **Runtime upgrades.** Node minor versions, Postgres point releases, Firebase SDK majors. Steady cadence keeps the platform on supported versions. **Performance.** [Monitoring](/principles/diligence/monitor-day-one) catches drift early — a query that was instant at 1k rows starts slowing at 50k, a bundle creeping past a budget, a Cloud Function cold-start showing up in real analytics. Caught early, these are short fixes. Caught late, they're a sprint. **Bug fixes.** The actual ones. Edge cases that surface only at scale. [Fixed and shipped the same week](/principles/diligence/incident-response), not added to a backlog. **Monitoring tuning.** Sentry signal-to-noise drift, dashboards going stale, alerts that no longer correlate with real problems. Pruned so the alerts you do get mean something. **Third-party API drift.** Stripe, Resend, OAuth providers, webhook signatures — the change-log work that prevents finding out you're broken by losing a week of payments. **New features.** This is the part most maintenance offerings can't do. The retainer is the same retainer that builds — same person, same cadence, same architecture judgement. When the founder needs a CSV export on the admin page, an integration with a new payment provider, a redesign of the onboarding flow, or a whole new module — that ships in the same retainer. The maintenance work and the new build work share a task board; you set the priorities. There's no separate engagement, no re-scoping, no upsell, and no handoff to a different team that doesn't know the codebase. ## Why Same Person and Same Retainer Matters The classic split — build team ships v1, hands off to a maintenance team — destroys the context that makes maintenance fast. The build team never sees how their decisions hold up in production. The maintenance team works forensically, afraid to touch things they don't understand. Velocity drops on both sides. [Single-person continuity](/principles/philosophy/solo-model) inverts that. The same engineer who shipped the auth flow knows why session tokens expire after 12 hours instead of 24. The person who chose Firestore over Postgres knows which queries were intentionally denormalized and which were accidental. When someone needs to inherit the codebase from elsewhere, the onboarding pays for itself by week three — and after that, the same continuity holds. [The retainer model](/) holds this together. Weekly billing, weekly cadence, async-first communication, [pause anytime](/principles/diligence/pause-not-end) through Stripe when there's genuinely nothing on the board. The same engagement that builds new features carries the maintenance work without a contract change, a different rate card, or a different person picking up the phone. ## Who This Fits Founders post-launch, with real users, where the product is critical but doesn't need a full-time engineer. Operators who acquired a small SaaS and need someone competent keeping it healthy. Solo founders running products built years ago that still print money and need senior eyes once a week. Teams whose original contractor moved on and now need someone to inherit the work and keep it running. Founder-built apps — vibe-coded, no-code-graduated, weekend-project-grown — that crossed the line where breakage costs real money. The pattern across all of these: a working product, real users or real revenue, and a founder who doesn't want to think about whether the dependencies are up to date. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a Code Audit Looks Like](/jurij/p/what-a-code-audit-looks-like)** — start here when you're inheriting a codebase you don't yet understand - **[What a Legacy App Modernization Looks Like](/jurij/p/what-a-legacy-app-modernization-looks-like)** — when the maintenance burden is symptomatic of a deeper problem - **[What a Fractional CTO Engagement Looks Like](/jurij/p/what-a-fractional-cto-engagement-looks-like)** — when "keep it alive" expands into "build the next chapter" - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/null-bytes-dead-streams-last-chunk --- title: Null Bytes, Dead Streams, Last Chunk url: https://varstatt.com/jurij/p/null-bytes-dead-streams-last-chunk author: Jurij Tokarski date: 2026-04-24 description: SSE adds overhead for mixed events, silent streams hang without error, and the last audio chunk vanishes on page close — three LLM streaming fixes. section: Blog (https://varstatt.com/jurij/archive) tags: ai (https://varstatt.com/jurij/c/ai), software-design (https://varstatt.com/jurij/c/software-design) --- Streaming LLM output to a browser means wiring together SSE, TCP, fetch, and browser lifecycle APIs that weren't designed for this combination. Each one has constraints that only surface when you integrate them. ## The Parser That Choked Server-Sent Events is the natural choice for streaming. SSE supports multiple event types via the `event:` field and handles multiline JSON by splitting across `data:` lines. But when every chunk needs an `event:` line, one or more `data:` lines, and a blank line delimiter — and you're sending hundreds of small text fragments interleaved with structured tool call events — the framing adds up and the parser becomes more complex than the problem requires. A null byte as the delimiter is simpler. `\0` is rare enough in practice — it can appear as `\u0000` in JSON but almost never does in LLM output or natural language — that it works as a reliable record separator without escaping. ```javascript // Server: wrap each event function sendEvent(stream, event) { stream.write(JSON.stringify(event) + '\0'); } // Client: split and route let buffer = ''; decoder.on('data', (chunk) => { buffer += chunk; const parts = buffer.split('\0'); buffer = parts.pop(); // keep the incomplete trailing segment for (const part of parts) { if (part) handleEvent(JSON.parse(part)); } }); ``` Each event is a JSON object with a `type` field — `text_chunk`, `tool_call`, `tool_result`, `done`. The client splits on null bytes, parses each segment, routes by type. Text chunks accumulate in the UI. Tool events trigger loading states or commit structured data. ## The Stream That Stopped Talking TCP keepalive keeps a connection open. It doesn't tell you the connection has gone silent at the application level. Occasionally — maybe once every few hundred sessions — a stream stops mid-sentence. No error event. No close event. The connection is alive, the response is still "streaming," and the user is staring at a half-finished message with a spinner that will never resolve. The LLM API hasn't errored — it just stopped sending chunks. An idle timer catches this. Reset it on every incoming chunk. Fire it if silence crosses a threshold. ```javascript let idleTimer; function resetIdleTimer(controller) { clearTimeout(idleTimer); idleTimer = setTimeout(() => { controller.abort(); }, 30_000); } stream.on('data', (chunk) => { resetIdleTimer(controller); processChunk(chunk); }); stream.on('end', () => { clearTimeout(idleTimer); }); ``` Thirty seconds is generous for interactive chat — users notice after five. The threshold isn't the important part. The pattern is: connection-level timeouts don't catch application-level silence. You need to track it yourself. ## The Chunk That Vanished Browsers kill in-flight `fetch()` calls during page unload. If you stream audio in chunks via POST, the final chunk — whatever is still buffered when the user stops recording or closes the tab — lives in memory until the next flush. That flush never happens. The final segment of every session is silently dropped. ```javascript // Killed on page close: await fetch('/v3/audio/stream_chunk', { method: 'POST', body: chunk }); // Survives: fetch('/v3/audio/stream_chunk', { method: 'POST', body: chunk, headers: { Authorization: `Bearer ${token}` }, keepalive: true, }); ``` No `await`. No `.then()`. You can't await a response during unload — any result is swallowed. Fire and forget. The browser queues the request and completes it even after the page is gone, as long as the total payload is under ~64KB. `navigator.sendBeacon()` survives unload too, but it doesn't support custom headers. If your backend expects an auth header, `fetch({ keepalive: true })` gives you the full request API. ## The Gaps Between Protocols Every integration has these. You wire together two or three tools that work fine on their own, but nobody tested them together — and no documentation covers the seams. The workarounds aren't published as best practices. They accumulate as know-how, one project at a time. These are three I've accumulated for LLM streaming. --- # https://varstatt.com/jurij/p/200-ok-data-wrong --- title: 200 OK, Data Wrong url: https://varstatt.com/jurij/p/200-ok-data-wrong author: Jurij Tokarski date: 2026-04-21 description: Imagen rewrites your prompt, Lambda corrupts your binary buffer, GSC returns empty rows, and structured output truncates without error. section: Blog (https://varstatt.com/jurij/archive) tags: debugging (https://varstatt.com/jurij/c/debugging), ai (https://varstatt.com/jurij/c/ai) --- [The Production Bugs That Never Threw an Error](/jurij/p/production-bugs-that-never-threw-an-error) was about systems that reported success while running the wrong thing — stale tokens, cached artifacts, stripped paths. These five are different. Every one is an API that accepted valid input, returned a clean 200, and delivered the wrong output. The call worked. The result didn't. ## The Image That Wasn't What I Asked For Imagen has a prompt rewriter enabled by default — an LLM that rewrites your prompt before generation to "add more detail and deliver higher quality images." The rewritten version is only returned in the API response if your original prompt is under 30 words. Above that threshold, you get an image generated from a prompt you never see. The image I got back was valid, well-composed, and completely wrong. The main subject was replaced by something adjacent. The response was 200. No flag, no warning, no indication that the input was rewritten. I assumed the safety filter had intervened — but the [documentation](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/image/responsible-ai-imagen) says safety filters either block with an error or omit images entirely. They don't silently substitute. The prompt rewriter does. Setting `enhancePrompt: false` in the request disables it. After that, the images matched the prompt. ## The File Search That Found Nothing For retrieval I was uploading source documents via the Files API with file search enabled. One batch worked correctly. Another batch would upload without error but return no results in search. The difference was the filename. The batch that failed was uploaded with a generic name — something like `upload_1` — with no extension. File search uses the filename to infer content type before indexing. A file without a recognized extension gets indexed as an unknown type, and the error is generic enough that it looks like a search quality issue rather than an upload problem. Adding `.pdf`, `.txt`, or the correct extension to every filename at upload time fixed retrieval immediately. ## The Transcription That Came Back as Garbage A voice dictation feature recorded audio in the browser and sent the blob to a Lambda Function URL behind CloudFront. The Lambda passed it to Whisper. The transcription came back — but it was nonsense. No error, no rejection, just wrong text. Lambda Function URLs base64-encode binary request bodies at the HTTP interface layer. The event includes an `isBase64Encoded` flag, but if you treat the body as raw bytes in all cases, the buffer is silently corrupted. Whisper doesn't throw on bad audio — it produces garbage. ```javascript const audioBuffer = event.isBase64Encoded ? Buffer.from(event.body, 'base64') : Buffer.from(event.body); ``` Any Lambda that accepts binary payloads — audio, images, PDFs — needs to check that flag before consuming the body. The cost of missing it is not an error. It's wrong output that looks like a model quality issue. ## The Search Console That Had No Traffic I wired up Google Search Console data fetching for a site with real traffic — I could see it in the GSC web UI. The API call went through, no errors, no 403. It returned zero rows. The site was registered as a domain property. Domain properties require `sc-domain:example.com` as the `siteUrl`, not `https://example.com`. The API doesn't say "wrong format" or "property not found." It returns empty data as if the site has zero search traffic. ```javascript // Returns empty data, no error const res = await webmasters.searchanalytics.query({ siteUrl: 'https://example.com', requestBody: { startDate, endDate, dimensions: ['query', 'page'] }, }); // Returns actual data const res = await webmasters.searchanalytics.query({ siteUrl: 'sc-domain:example.com', requestBody: { startDate, endDate, dimensions: ['query', 'page'] }, }); ``` Calling `sites.list()` shows the exact format the API expects. I spent time checking date ranges and service account permissions before running that call and seeing `sc-domain:` staring back at me. ## Silent Truncation in Structured Outputs With `json_schema` and `strict: true`, OpenAI guarantees valid JSON — except when the response hits `max_output_tokens`. When that happens, the stream ends with truncated JSON and `response.status` set to `'incomplete'`. This is not surfaced as an error. `response.completed` still fires normally. ```typescript if (event.type === 'response.completed' && 'response' in event) { if (event.response.status === 'incomplete') { const reason = event.response.incomplete_details?.reason || 'unknown'; log.error('Response truncated', { reason }); await params.onChunk('internal.error', 'The AI response was too long and got cut off.'); await params.onChunk('internal.finished', ''); return; } captureUsageStats(event.response.usage); } ``` This one had a bonus bug that made it harder to find. Before OpenAI supported structured output streaming, I used XML-like tags in the prompt to get parseable responses — ``, ``, that kind of thing. When structured outputs shipped, I switched to `json_schema` but left the XML parser in the catch branch: ```typescript try { return JSON.parse(input); } catch (jsonError) { return parseXMLResponse(input); // left in "just in case" } ``` When a truncated JSON response hit this code, `JSON.parse` failed, the catch branch fired, and the XML parser found no tags. It returned `nextAction: null` with the entire raw JSON string stuffed into the message field. The failure surfaced as a null-check bug three layers downstream — not as a parser problem. Dead code from a previous architecture, silently eating every truncation error. ## What These Five Have in Common Every failure surfaced downstream as something that didn't look like an API problem. Corrupted audio looked like a model quality issue. Empty GSC results looked like a permissions problem. Truncation looked like a null-check bug three layers away. The API boundary said success, and the real problem hid behind that signal. The only reliable defense is asserting on the output, not the status code — the kind of thing a [code audit](/jurij/p/what-a-code-audit-looks-like) catches systematically. Check that the image matches the prompt. Check that the buffer is actually binary. Check that the response has rows. Check that the JSON is complete. If you only verify that the call succeeded, you'll find the failure when your users do. --- # https://varstatt.com/jurij/p/what-a-code-audit-looks-like --- title: What a Code Audit Looks Like url: https://varstatt.com/jurij/p/what-a-code-audit-looks-like author: Jurij Tokarski date: 2026-04-20 description: Code audit, software audit, technical due diligence — same work, different buyers. One week, $997, prioritized by risk: security, architecture, tech debt. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Vibe coding cleanup is becoming its own job category. Collins Dictionary named "vibe coding" word of the year in 2025. TechCrunch ran a piece on the rise of "AI babysitters" — developers hired not to write code, but to audit what the tools already wrote. Cursor, Lovable, v0, Bolt.new, Replit Agent ship features fast. They also ship silent problems no linter catches and no test suite covers, because no test suite was written. One developer put it bluntly: "10x the productivity measured by lines of code written, but 1/100th the quality measured by pain in the ass to clean up." An AI code audit — call it vibe code review, call it cleanup — is the senior pair of eyes that should have been there during generation, applied after the fact. ## Yes, I Use the Same Tools You Do The obvious objection: *"You're charging $997 to audit code I generated with Cursor, but you're using Cursor too — what makes your output different from mine?"* The tools are the same. Cursor, Claude Code, Copilot, v0, Bolt — I use them daily. They make me 2-3x faster than I was without them. They don't make me a different kind of engineer. The thing they amplify isn't writing speed; it's whatever judgment you bring to the conversation. I've been shipping commercial software since 2011. Fifteen years of debugging things at 2am that worked fine in dev, of inheriting codebases from previous developers who "got it working" and disappeared. None of that experience is replaced by AI tooling. AI accelerates the part of the job I was already good at — typing — and leaves the hard part untouched. The hard part is knowing where the bombs are buried. ## The First 80% Is Easy. The Last 20% Is Where Production Lives. Software is shaped like an iceberg. The visible 80% — features that work on the happy path — is what AI tools generate fluently. Describe what you want, the model produces working code, you click through the flows, everything looks fine. That part has gotten cheap. The remaining 20% is what keeps the product alive in production. It's a list of things that look small until they aren't: - [Race conditions that fire once a week](/jurij/p/using-firestore-transactions-to-handle-race-conditions) when two users do something at the same instant - [Error handling that quietly swallows failures](/jurij/p/200-ok-data-wrong) the AI didn't think to surface - [A query that's instant on a 200-row dev database](/jurij/p/works-locally-fails-after-deployment) and times out at 50,000 rows - Auth that validates token format but doesn't check expiry, scope, or revocation - A deploy pipeline that "works" until it doesn't, with no visibility into what changed - Cache invalidation that's correct on every page except the one that matters - A third-party webhook that arrives in the wrong order one time in five hundred - A data migration that lost three rows six months ago and nobody noticed None of these break the demo. All of them break the product. Most don't surface as crashes — they surface as customers churning quietly, support tickets that don't resolve, metrics drifting without an obvious cause. AI tools generate the first 80% beautifully and have no concept of the second 20% existing. The audit is the second 20%. I'm not grading the code AI generated against AI's standard. I'm grading it against fifteen years of watching production fail in specific, repeatable ways. ## What the Audit Covers A code audit service touches four areas, roughly in priority order: **Security.** Auth flows, input validation, exposed secrets, API key handling. AI generates auth code that looks correct — and often is. Sometimes a missing `httpOnly` flag, a JWT verified without checking the signing algorithm, an env variable committed because the `.gitignore` template didn't catch it. [Polish over security is a real cost](/jurij/p/when-polish-over-security-costs-real). **Architecture.** Component structure, data flow, dependency management. AI-generated code produces coherent local decisions and incoherent global structure. State lives in three places. The same fetch call appears in four files. None of it breaks anything until someone needs to change it. **Performance.** Re-renders, slow queries, bundle size. A component re-rendering on every keystroke is invisible on a MacBook and noticeable on a phone on a slow connection. A query without an index works fine in development. These are the [bugs that were actually the prompts](/jurij/p/three-bugs-that-were-actually-my-prompts). **Technical debt.** Dead code, inconsistent patterns, missing error handling. Every `catch (e) { console.log(e) }` is a failure that will look like a success. These accumulate quietly. [Silent failures that look like success](/jurij/p/production-bugs-that-never-threw-an-error) are the hardest to catch. The standard throughout: does this code handle failure gracefully? Can a new developer understand it in a week? Will it break when traffic doubles? ## The Deliverable Not a 50-page document nobody reads. One report, structured by severity. Each issue: the file and line, what's wrong, how to fix it. Organized by risk — security first, then anything that breaks under real conditions, then debt that slows the team down. You know exactly where to start. This is the [quality gate](/principles/delivery/quality-gates) applied to inherited code, and the [scout rule](/principles/delivery/scout-rule) for what to do next. The audit is current state. What you do with it is the cleanup. ## Code Audit, Software Audit, Technical Due Diligence — Same Work, Different Buyer The same week of work gets called three different things depending on who's writing the check. **Code audit** is the founder framing. You built something with Cursor or inherited it from a contractor and you want a senior pair of eyes before you ship the next round of features. The output is a fix-it list ordered by risk. Same shape as a code audit service from a bigger firm, minus the multi-phase SOW. **Software audit** is the existing-team framing. You have a SaaS in production, the original developer left, and the team that owns it now isn't sure what's a landmine and what isn't. A software audit (or codebase audit, or application audit) maps the surface area: where the bombs are, what's load-bearing, what can be touched safely. Same deliverable as the code audit, written for the team that has to live with it. **Technical due diligence** is the investor framing. Sometimes it's vendor-side — a startup preparing for a fundraise or acquisition runs its own technical due diligence first so the investor's diligence doesn't surface anything ugly. Sometimes it's buy-side — an acquirer or VC wants an independent software due diligence read on a target before the term sheet. Same scan, framed as a technical due diligence report: what's the technical risk, what's the cost to fix, what does the team look like. The TDD framing also gets formalised into checklists more often — happy to follow a specific technical due diligence checklist if the investor provides one. The work underneath is identical. Security, architecture, performance, technical debt — graded against fifteen years of watching production fail. The output is the same one report, the price is the same $997 for the week. What changes is the cover: who's reading it, and which decision it informs. ## Who This Is For Founders who built with Cursor, Lovable, v0, Bolt.new, or Replit Agent and need a senior code review for hire. Startups preparing for fundraising who need an independent technical assessment or vendor-side technical due diligence. VCs and acquirers who need a fast, independent technical due diligence consultant on a target. Teams inheriting a contractor's codebase. [Non-technical founders who need code health translated into business risk](/jurij/p/how-should-non-technical-founders-evaluate-developers). One week, $997, fixed deliverable. This sits inside the [Varstatt retainer](/) — subscribe, the audit ships, cancel — same as any other shape. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a Legacy App Modernization Looks Like](/jurij/p/what-a-legacy-app-modernization-looks-like)** — when the audit finds problems too deep to patch - **[What a Fractional CTO Engagement Looks Like](/jurij/p/what-a-fractional-cto-engagement-looks-like)** — when you need ongoing senior eyes after the audit - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — when the audit fixes are best handled as steady-state work - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/filling-forms-no-tool-can-template --- title: Filling Forms No Tool Can Template url: https://varstatt.com/jurij/p/filling-forms-no-tool-can-template author: Jurij Tokarski date: 2026-04-16 description: Every tender form is different, templating tools need placeholders you can't insert, and markdown round-trips destroy the document. section: Blog (https://varstatt.com/jurij/archive) tags: ai (https://varstatt.com/jurij/c/ai), software-delivery (https://varstatt.com/jurij/c/software-delivery) --- [It Works, But You Can't Ship It](/jurij/p/it-works-you-cant-ship-it) covered the compliance wall — code execution sandboxes can fill DOCX forms, but they only exist in regions that don't match every customer's data residency policy. This post covers what I learned building the feature before that discovery. ## Every Form Is Different Tender response forms have nothing in common with each other. One agency sends a form with merged table cells and numbered question blocks. The next sends checkboxes inside conditional formatting with section breaks in unexpected places. There is no shared structure, no recurring field names, no predictable layout. Every DOCX templating tool I evaluated — `docx-templates`, `easy-template-x`, `docxtemplater` — works the same way: you prepare a template with `{variable_name}` placeholders, pass in data, get a rendered document. That assumes you control the template. Tender forms come from government agencies. You don't control anything. You can't insert placeholders into a form you receive the day the tender opens. Filling these forms requires understanding an arbitrary document's structure, finding the insertion points, and knowing what content goes where. That's not a deterministic templating problem. It's a comprehension problem. ## DOCX Is a ZIP of XML My first [PoC](/jurij/p/what-a-2-week-poc-looks-like) tried the next obvious thing: extract the DOCX to markdown, send it to the model with draft content, get back filled markdown, convert to a new DOCX. Clean pipeline, completely useless output. The regenerated document lost every merged cell, every checkbox, every conditional format. The output was a different document that happened to contain similar text. A DOCX file is a ZIP archive of XML. `word/document.xml` holds the content in OOXML format. The correct approach is to give the model the original binary, let it read the XML, find the insertion points, write modifications back, and save the modified ZIP. XML surgery on the original file — not regeneration. That's the only reliable way to fill DOCX forms with AI — operate on the XML, not on a lossy text conversion. And the only way to do this through an API, without deploying a separate Python service, is a code execution sandbox. Both OpenAI's `code_interpreter` and Anthropic's code execution tool provide a sandboxed Python environment where `python-docx` is available and the model can operate on the file directly. Once that architecture clicked, the API quirks started. ## OpenAI: The File Goes in the Container My first attempt passed the uploaded DOCX as an `input_file` content block in the user message — the pattern you'd use for images or PDFs. ``` Expected context stuffing file type to be a supported format... but got .docx ``` Context stuffing only supports PDFs, images, and plain text. The file has to go into the `code_interpreter` container instead: ```typescript tools: [ { type: 'code_interpreter', container: { type: 'auto', file_ids: [uploadedFile.id] } } ] ``` The message itself is plain text — you tell the model the filename so it knows what to look for in the sandbox. No file reference in the content block at all. Getting the filled file back had its own problem. The SDK exposes `client.containers.files.content`, which looks callable. It isn't — it's a resource object. The working call is `client.containers.files.content.retrieve(containerId, fileId)`. Neither the types nor the error message make this obvious. I found it by running `Object.getOwnPropertyNames` on the object at runtime. ## Anthropic: Five Things at Once Claude has the same capability, but it requires five specific pieces in a single request. Miss any one and you get a cryptic failure. The file upload needs an explicit MIME type — not inferred from the extension. The API call needs two beta flags active simultaneously: `files-api-2025-04-14` and `code-execution-2025-08-25`. The file must be referenced as `container_upload` in the content block — not `document`, not `file`. The tool declaration needs the full versioned type string `code_execution_20250825`. And the download call needs the same beta flags passed again. ```typescript const response = await client.beta.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 16384, betas: ['files-api-2025-04-14', 'code-execution-2025-08-25'], messages: [{ role: 'user', content: [ { type: 'text', text: userMessage }, { type: 'container_upload', file_id: uploaded.id }, ], }], tools: [{ type: 'code_execution_20250825', name: 'code_execution' }], }); ``` No single documentation page covers all five requirements together. Each piece is documented somewhere. The combination isn't. ## GPT-5.1 Broke the Document I tested the form-filling flow across GPT-5.1, 5.2, and 5.4, on both Azure OpenAI and the public API. GPT-5.1 on Azure — our production deployment — wrote code that opened the DOCX but ignored formatting preservation entirely. Merged cells collapsed, checkboxes vanished, section breaks shifted. The output was a broken document. Same result on the public API — not an infrastructure issue, a model capability issue. GPT-5.2 was inconsistent: partially filled on one test, failed on the next. GPT-5.4 was the first in the lineup that reliably understood the OOXML structure, applied targeted modifications with `python-docx`, and returned a valid binary with all formatting preserved. ## Every Claude Model Could Do It After the GPT results I tested Claude 4.6 — Opus, Sonnet, and Haiku — through Anthropic's code execution sandbox. Opus and Sonnet completed the task cleanly. The OOXML structure stayed intact, insertions landed in the right cells, formatting survived the round trip. Haiku was inconsistent — similar to GPT-5.2, partially filling on some runs and failing on others. The gap between the top-performing models was stark. GPT-5.1 couldn't preserve the structure at all. Claude Opus and Sonnet preserved it reliably. The model version matters more than the provider for this task. But which model you can actually deploy depends on where your customer's data is allowed to live — a [tech strategy](/discovery/tech-strategy) decision, not a code decision. And the sandbox those models need [doesn't exist in every region](/jurij/p/it-works-you-cant-ship-it). --- # https://varstatt.com/jurij/p/what-a-pwa-build-looks-like --- title: What a PWA Build Looks Like url: https://varstatt.com/jurij/p/what-a-pwa-build-looks-like author: Jurij Tokarski date: 2026-04-14 description: PWA development service in 6 weeks. The most budget-efficient way to ship an app: web + installable + app stores from one codebase. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Most founders who come to me with a mobile app idea assume they need two codebases — one for iOS, one for Android. A progressive web app development engagement usually changes that assumption by week one. A PWA runs in the browser, installs to the home screen like a native app, works offline, and can send push notifications. One codebase. No app store approval. No 30% cut on transactions. ## PWA Is the Most Budget-Efficient Way to Launch an App The default startup playbook for "I need a mobile app" is wrong by a factor of three to five. Founders walk in assuming they need a website, an iOS app, and an Android app — three codebases (or one with platform forks), three deployment pipelines, two app store review cycles. The total cost-to-launch ranges from "uncomfortable" to "burns the seed round." A PWA collapses that stack. **One codebase in React. One deploy pipeline. One set of bugs.** It runs in the browser as a website and installs to the home screen as an installable app. Google Play accepts a PWA wrapped with Bubblewrap or PWABuilder cleanly. The iOS App Store is harder — Apple often rejects thin PWA wrappers, so a Capacitor shell with some real native integration is usually needed to get through review. App store presence becomes a derivative of the PWA, not a separate codebase. The honest math: a PWA gets you to launch **3-5x faster** than building website + native iOS + native Android in parallel. Not because the technology is more powerful — because you're maintaining one thing instead of three. Every bug fix lands once. Every feature ships once. The trade-off is real. PWAs don't get every native API the same way. Web Bluetooth and Web NFC exist but are Android-only and limited; advanced camera controls, deep iOS integrations, and reliable background sync are all weaker than native. iOS in particular has historically been the weak link — Apple has improved PWA support gradually, but features land later there than on Android. For most products this list is irrelevant. Dashboards, portals, booking tools, content apps, AI chat interfaces, community apps, internal tools, marketplaces — none of them need NFC. They need fast load, reliable offline, push notifications, home-screen install. PWAs deliver all four. The [PWA vs native app question reduces to one test](/principles/discovery/find-the-core): does your core interaction depend on hardware? If yes — camera scanning, Bluetooth pairing, NFC tapping — go native. For everything else, a PWA gets you 80% of the native experience for 20% of the cost. ## The Stack: React and Firebase React + Firebase is the [default stack](/principles/delivery/default-stack) I use across engagements. Firebase Auth handles login. Firestore's offline persistence syncs automatically when the connection drops — no custom sync logic, no queuing layer. Cloud Functions handle server-side work without standing up infrastructure. Every part of it amplifies the budget argument. Auth, real-time database, serverless backend, hosting, push notifications — one platform. No infrastructure to provision, no DevOps to staff. The combined cost stays well under $100/month for most early-stage products. ## What Each Week Looks Like $997/week, six weeks. **Week one:** infrastructure. Firebase project, React or Next.js scaffold, auth, Firestore schema, PWA manifest. By end of week one the app is installable from a browser — a real install on your phone. **Weeks two and three:** core features. Screens, Firestore reads and writes, Cloud Functions where needed, Firebase Auth flows. Each feature reviewed and deployed before the next starts ([building in composable pieces](/jurij/p/build-apps-like-lego-bricks)). **Week four:** offline and performance. Service worker, caching strategies, background sync. This is where react pwa development gets honest — a production service worker isn't the two-line tutorial version. [Cache invalidation, failed-request queuing](/principles/delivery/quality-gates), and update flows all need real decisions. **Weeks five and six:** launch. Firebase Hosting deploy, push notification setup, analytics, Firestore cost review. You end week six with a web app that works offline, installs from a URL, and sends push notifications. Production, not staging. ## The Deploy Speed Argument One PWA difference founders consistently underrate: deploys. A native app update goes through app store review — hours at minimum, sometimes days. [A PWA update ships the moment you push to Firebase Hosting](/principles/delivery/continuous-flow). Users get the new version on next page load. For an early-stage product still finding its shape, that gap is enormous. Discover a critical bug at 2pm, fix it by 3pm, every user has the fixed version by 3:05pm. The native equivalent is days of exposure for the same fix. ## Who This Works For Founders who need mobile-first without two codebases. Teams replacing internal tools where [field staff or warehouse workers need offline reliability](/jurij/p/the-algorithm-that-transformed-warehouse). Products that outgrew Bubble, Glide, or Notion and now need real infrastructure. If you're not sure whether a PWA is the right shape, the [tech strategy](/discovery/tech-strategy) and [build cost](/discovery/build-cost) tools are a good place to start. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a 6-Week MVP Build Looks Like](/jurij/p/what-a-6-week-mvp-build-looks-like)** — same retainer shape, framed as v1 of a SaaS rather than mobile-first - **[What a 2-Week PoC Looks Like](/jurij/p/what-a-2-week-poc-looks-like)** — when one feature (push, offline, install) needs a feasibility test first - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — once the PWA is live and needs steady-state care - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/svg-animation-is-not-dom-animation --- title: SVG Animation Is Not DOM Animation url: https://varstatt.com/jurij/p/svg-animation-is-not-dom-animation author: Jurij Tokarski date: 2026-04-13 description: Rebuilding an old chart race challenge in React taught me five things SVG handles differently than the DOM. Coordinates, transforms, text, and more. section: Blog (https://varstatt.com/jurij/archive) tags: project-stories (https://varstatt.com/jurij/c/project-stories) --- I had a bar chart race sitting in a private repo for over five years. A coding challenge from 2020 or so — built it, moved on, forgot about it. When I started building the [toolkit](/toolkit) on varstatt.com — free browser-based dev tools — it seemed like an obvious candidate to resurrect. The new version would be React with SVG, part of a suite: bar chart race, line chart race, area chart race, bubble chart race. Same idea, four visualizations. Upload a CSV, watch the data animate. Every animation technique I reached for broke in a way I didn't expect. ## CSS Transitions Do Nothing on Geometric Attributes First attempt on the line chart: CSS transitions on SVG elements. `transition: cx 300ms ease, cy 300ms ease` on the `` dots tracking data points. Expected smooth interpolation between positions. The dots snapped. No animation. Chrome, Firefox, same result. ```css /* This does nothing useful */ circle { transition: cx 300ms ease, cy 300ms ease; } ``` CSS transitions animate CSS properties. `cx`, `cy`, `r`, `points` are not CSS properties — they're SVG attributes. They live in the DOM, but the browser's animation engine doesn't see them. You can change them from JavaScript and the element moves, but there's no interpolation. It jumps. `transform` and `opacity` work because those are actual CSS properties that SVG elements happen to support. Everything that describes SVG geometry — positions, sizes, path data — sits outside that system. ## Two Animation Systems on the Same Property The bar chart race had horizontal bars with CSS transitions on `top` and `width`. I set `transition: top 1000ms ease-out, width 1000ms ease-out` and advanced frames with `setInterval`. That worked. Then I switched playback to `requestAnimationFrame` for continuous interpolation — a float position updating at ~60fps instead of integer jumps every second. The bars turned jittery. Every RAF tick (~16ms) set a new `top` value. Each value restarted the 1000ms CSS transition before the previous one completed. The browser's transition engine was fighting the RAF loop. Two animation systems controlling the same property, neither finishing. ```jsx // RAF updates position every ~16ms const top = rank * BAR_HEIGHT; // CSS transition: "top 1000ms ease-out" // Every 16ms: cancel current transition, start new 1000ms transition // Result: jittery mess ``` The fix was to remove every CSS transition from every element that RAF touches. Bar positions, widths, SVG coordinates, label positions — all computed directly from the playback float. ```jsx // RAF computes position directly — no CSS transition const interpolatedRank = prevRank + (nextRank - prevRank) * frac; const top = interpolatedRank * BAR_HEIGHT; // style={{ top, transition: 'none' }} ``` **CSS transitions and `requestAnimationFrame` are competing strategies.** They solve the same problem differently. Layering both on the same property means neither works. I ended up with zero CSS transitions on animated properties across all four chart types. ## Colors That Follow Position Instead of Identity Bubble chart. Bubbles sorted by value each frame so the largest renders on top (correct z-order). Colors assigned by array index after sorting. Frame 1: Python is biggest, gets index 0, gets blue. Frame 2: JavaScript overtakes Python, gets index 0, gets blue. Python drops to index 1, turns orange. Every frame where the lead changes, half the bubbles swap colors. ```javascript // before: color by sorted position const sorted = [...items].sort((a, b) => b.value - a.value); sorted.forEach((item, i) => { item.color = palette[i % palette.length]; }); ``` The fix: build a color map keyed by series name at parse time, before any sorting happens. ```javascript const colorMap = useMemo(() => { const map = {}; data.seriesNames.forEach((name, i) => { map[name] = palette[i % palette.length]; }); return map; }, [data.seriesNames, palette]); ``` `data.seriesNames` preserves the original CSV column order. It never changes during playback. Sorting for z-order still happens, but it only affects render order, not color. Any visualization where items reorder needs visual properties assigned by identity, never by current array position. ## Ranks That Re-Sort Every Tick Same bar chart race. I was re-sorting bars by their interpolated value on every RAF tick. Values cross each other mid-frame constantly — Python at 11.83 overtakes Java at 11.81 for one tick, then Java is back on top the next. The bars flickered between positions 60 times a second. The fix: **compute sort order only at whole frame boundaries**, store it in a pre-computed array, then interpolate rank positions as floats between frames. ```javascript const frameRanks = useMemo(() => data.frames.map(frame => { const sorted = data.seriesNames .map(name => ({ name, value: frame[name] })) .sort((a, b) => b.value - a.value); const ranks = {}; sorted.forEach((s, i) => { ranks[s.name] = i; }); return ranks; }), [data]); // interpolate rank as a float const rank = (currentRanks[name] ?? idx) * (1 - frac) + (nextRanks[name] ?? idx) * frac; const top = rank * barHeight; ``` A bar sliding from position 3 to position 1 moves smoothly over the full frame duration instead of jumping. No flickering. ## Curves That Rewrite Their Own History The line and area charts used Catmull-Rom splines. The animation draws a line progressively — like a pen moving across the screen. Curves looked great. The problem showed up immediately: as the animation advanced and new points entered the spline, the entire line wiggled. Segments already "drawn" shifted into new positions on every frame. Catmull-Rom computes each segment's control points from the tangent at its endpoints, and the tangent at any point depends on its neighbors. Add a new neighbor, all the tangents change. Feed completed points into the spline function as the animation progresses and every frame recalculates every segment. **The old part of the curve is never stable.** The fix split the work into two memos with different dependency arrays. ```javascript // Phase 1: compute ALL segments from full dataset, once const stableGeometry = useMemo(() => { const allPoints = data.frames.map((f, i) => ({ x: xScale(i), y: yScale(f.values[seriesName]) })); const segments = precomputeSegments(allPoints); return { allPoints, segments }; }, [data, width, height]); // Phase 2: reveal progressively, split active segment with de Casteljau const chartData = useMemo(() => { const { segments } = stableGeometry; const wholeIdx = Math.floor(position); const frac = position - wholeIdx; let d = segments.slice(0, wholeIdx).map(s => s.pathData).join(''); if (frac > 0 && segments[wholeIdx]) { const partial = splitBezierAt(segments[wholeIdx], frac); d += partial.pathData; } return d; }, [stableGeometry, position]); ``` Pre-compute the full curve from all data points. Completed segments render byte-identical every frame. The active segment gets split at the exact fractional position using de Casteljau subdivision. Historical geometry never depends on current playback position. ## The Common Assumption Each problem came from expecting SVG elements to behave like DOM elements when animated. CSS transitions ignore geometric attributes. RAF and transitions fight over the same values. Array indices aren't stable identifiers when sort order changes. Spline algorithms that look local are global. The fix was the same every time: compute everything yourself, from one source of truth. Zero CSS transitions on animated properties, all positions derived from a single playback float. The four chart tools are part of the [varstatt.com/toolkit](/toolkit) — free, browser-based, no sign-up: [bar chart race](/toolkit/chart-bar), [line chart race](/toolkit/chart-line), [area chart race](/toolkit/chart-area), [bubble chart race](/toolkit/chart-bubble). The old repo from 2020 bears no resemblance to what shipped. --- # https://varstatt.com/jurij/p/what-a-legacy-app-modernization-looks-like --- title: What a Legacy App Modernization Looks Like url: https://varstatt.com/jurij/p/what-a-legacy-app-modernization-looks-like author: Jurij Tokarski date: 2026-04-10 description: Legacy application modernization services in 6 weeks: feature-by-feature migration, live app throughout, no big-bang rewrite, no frozen branch. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Most legacy application modernization projects fail because they try to do everything at once. The team freezes new features, spends three months on a parallel build, and does a big-bang cutover that either works or doesn't. Usually it partially doesn't. The shape I use for legacy software modernization is different: six weeks, feature-by-feature, live app running throughout. No frozen branch, no three-month dark period — targeted legacy code modernization that ships piece by piece. Most application modernization services price the work as a multi-month consulting engagement billed open-ended against a multi-phase SOW. This is the [weekly retainer](/) — $997/week, cancel anytime — applied to a modernization shape. Same scope an enterprise modernization vendor would pitch as a six-month roadmap, compressed to roughly six weeks because the surgical approach removes the parts that take the most time: the freeze, the parallel build, the cutover risk. ## Big-Bang Rewrites Are Where Companies Die Joel Spolsky wrote ["Things You Should Never Do, Part I"](https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/) in 2000. The thesis: throwing out working code to rewrite it from scratch is "the single worst strategic mistake any software company can make." Netscape did it. They lost three years and the browser war while Microsoft kept shipping. The essay is 25 years old. The lesson hasn't dated. A legacy codebase is a body of decisions, accidents, and battle-scars that earned its right to exist by serving real users. Many of them encode constraints — a billing edge case, a regulator's email, a migration from three years ago — that nobody on the current team remembers. The "stupid" code is often the only code that handles the real world correctly. A clean-slate rewrite throws all of that away and rediscovers it the hard way, in production. The real failure mode of a rewrite isn't bad code. The new code is usually fine. The failure is **the freeze**. Three months of stopped feature shipping. Customers feel it. Sales feel it. Competitors feel it. By the time the new version launches, the original problem — slow development, scared engineers — has been replaced by a new version of itself in TypeScript. And the business momentum is gone. ## The Surgery Doctrine: Coexistence Over Replacement Feature-by-feature surgery while the patient stays awake. The old app keeps running, taking traffic, generating revenue. The new app goes in alongside it, one feature at a time. Both apps share auth and the same backend, so a user signed into one is signed into the other. Each new-app surface has a [one-click fallback to the old behavior](/principles/delivery/feature-flags), live for as long as the transition takes. [Migrate in business order](/principles/delivery/wip-one), not developer order. New features ship first — they're the carrot that gets users to try the new app. Mirror work comes second. The everyday surfaces come last, when the new app is stable enough to handle them. When parity finally lands, the cutover itself is anticlimactic — a single flag flip per user, executed lazily on next login. Stability, not features, gates the final cutover. Parity is reached weeks before the cutover completes; the gap is spent on hardening. ## When the Big-Bang Argument Wins (Spoiler: Almost Never) There are situations where a clean-slate rewrite is the right call. The platform is genuinely abandoned. The runtime is end-of-life (PHP 5, Rails 2, Angular 1) with no incremental upgrade path. The data model is so wrong no schema migration can save it. These cases exist. They are rare. In every other case the answer is incremental. The codebase is fixable. The team thinks it isn't because they've been told "it works isn't enough" without being told what to do about it. This is the what-to-do-about-it. ## How This Differs From Most Application Modernization Companies Most application modernization consulting firms sell a discovery phase, then a roadmap phase, then an implementation phase, then a "stabilization" phase. Each one has its own statement of work and its own invoice. The total is six to nine months and a number that needs board approval. This isn't a fixed-scope engagement at all. It's the [Varstatt retainer](/) — $997/week, cancel anytime through Stripe — applied to a modernization shape. Six weeks is what the work usually takes. Some migrations finish in four. Some need eight because the codebase surfaces a sub-system that's bigger than it looked at week one. Either way, no re-scoping, no new SOW, no upsell — the weekly rate is the same and the cancel button stays one click away. The trade-off is real: this only works for codebases a single senior engineer can hold in their head. Million-line enterprise systems with five teams touching them need a different shape — one of the bigger application modernization companies is a better fit there. For typical SaaS, internal tools, and product codebases under ~100k lines, the small-shop approach ships faster and costs less than the firm equivalent. ## Signs the Codebase Needs This The usual signal isn't a crash. It's friction that accumulates: developers avoiding files they're not sure about, [onboarding a new hire taking weeks of oral history](/principles/diligence/documentation), deploys requiring a ritual. The app works, but it's becoming harder to change. If the original developer has left and you've **inherited a codebase** nobody fully understands, that's the clearest sign. The other case I see more often now: [vibe-coded apps from six months ago are already legacy code](/jurij/p/it-works-isnt-enough-for-commercial) — low confidence to change, no tests, no conventions. ## The Six-Week Shape $997/week, six weeks. Week one is assessment: I read the codebase, map dependencies, agree a migration order with you. Weeks two through five are migration — one to two features per week, each shipping independently on the [default stack](/principles/delivery/default-stack) (React, TypeScript, proven infra patterns). Week six is handoff: documentation, test coverage, onboarding notes. The output is a codebase built for the next developer. Clear patterns, consistent conventions, zero tribal knowledge. Before-and-after benchmarks on what matters: build times, deploy confidence, test coverage, onboarding time. If you're sitting on a codebase that works but costs you more time than it should, the [tech strategy](/discovery/tech-strategy) and [build cost](/discovery/build-cost) tools help frame the migration before we talk. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a Code Audit Looks Like](/jurij/p/what-a-code-audit-looks-like)** — diagnose the codebase first; modernize what the audit surfaces - **[What a Fractional CTO Engagement Looks Like](/jurij/p/what-a-fractional-cto-engagement-looks-like)** — when the modernization is one chapter of a longer build arc - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — once the modernization lands and the codebase needs steady-state care - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/45-tabs-i-stopped-opening --- title: 45 Tabs I Stopped Opening url: https://varstatt.com/jurij/p/45-tabs-i-stopped-opening author: Jurij Tokarski date: 2026-04-09 description: A JWT decoder, a mesh gradient engine, an animation system, and everything in between. Three of them outgrew the toolkit. section: Blog (https://varstatt.com/jurij/archive) tags: project-stories (https://varstatt.com/jurij/c/project-stories) --- The JWT decoder I used to reach for sent the token to a server. I noticed because I had DevTools open for something else and saw the POST. A JWT often carries user IDs, emails, roles, expiration data. I'd been pasting production tokens into a stranger's endpoint for months. That was the first tool I built for the [toolkit](/toolkit). The rest followed the same pattern: I needed something, the available options were ad-heavy or required sign-up or made network calls that didn't need to happen. A Base64 encoder doesn't need a backend. Neither does a regex tester, a color converter, or a hash generator. There are 45 tools now (48 as of mid-2026). No sign-up, no tracking, no data collection. Most run entirely in the browser — a few like DNS Lookup and SSL Checker need a server call by nature. ## The Catalogue **Encoding** — [Base64](/toolkit/base64), [JWT Decoder](/toolkit/jwt), [Image to Base64](/toolkit/img2b64), [Encrypt / Decrypt](/toolkit/encrypt), [Hash Generator](/toolkit/hash) **JSON & YAML** — [JSON Formatter](/toolkit/json), [JSON ↔ YAML](/toolkit/json-yaml), [YAML Validator](/toolkit/yaml) **Markdown** — [Markdown Preview](/toolkit/md), [Text Diff](/toolkit/diff), [HTML ↔ Markdown](/toolkit/html-md), [Markdown to PDF](/toolkit/md-pdf), [Markdown to DOCX](/toolkit/md-docx), [CSV Editor](/toolkit/csv) **Images** — [QR Code](/toolkit/qr), [Barcode](/toolkit/barcode), [Image Converter](/toolkit/convert), [Favicon Generator](/toolkit/favicon), [SVG Optimizer](/toolkit/svg), [Placeholder Images](/toolkit/placeholder), [Aspect Ratio](/toolkit/ratio) **Design** — [Mesh Gradient](/toolkit/mesh), [CSS Cover Art](/toolkit/covers), [Color Converter](/toolkit/color), [Text to Gradient](/toolkit/text-gradient) **Charts** — [Bar Chart Race](/toolkit/chart-bar), [Line Chart Race](/toolkit/chart-line), [Bubble Chart Race](/toolkit/chart-bubble), [Area Chart Race](/toolkit/chart-area) **Network** — [DNS Lookup](/toolkit/dns), [CORS Tester](/toolkit/cors), [SSL Checker](/toolkit/ssl), [OG Tag Validator](/toolkit/og), [HTTP Status Codes](/toolkit/http), [Robots.txt Validator](/toolkit/robots), [Sitemap Validator](/toolkit/sitemap), [User Agent Parser](/toolkit/ua) **Text** — [Regex Tester](/toolkit/regex), [Case Converter](/toolkit/case), [Slug Generator](/toolkit/slug), [Word Counter](/toolkit/words), [Copy Paste Characters](/toolkit/chars) **Generators** — [UUID](/toolkit/uuid), [Password](/toolkit/password), [Crontab](/toolkit/crontab), [Unix Timestamp](/toolkit/timestamp) Most are straightforward. Three outgrew the toolkit and became standalone npm packages. ## Text to Gradient The [Text to Gradient](/toolkit/text-gradient) tool and the [Mesh Gradient Generator](/toolkit/mesh) both needed the same thing: a way to turn an arbitrary input into a unique, stable visual. Same input, same gradient, every time. No database, no storage. A djb2-style 32-bit hash is all it takes: ```js function textHash(str) { let hash = 5381; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash) + str.charCodeAt(i); hash = hash >>> 0; } return hash; } ``` Everything derives from that number. `hash % palettes.length` selects the color palette. `seededRandom(hash + layerIndex * 1000)` generates position and opacity variation per layer. The same string always produces the same gradient — looks hand-crafted, costs nothing to store. The gradients themselves are layered `radial-gradient()` calls. There's no `mesh-gradient()` in CSS. What works is stacking 6-8 radial gradients positioned at organic spots — 15%, 37%, 63%, 82% — not pure corners or centers, which look algorithmic. Each one uses a `0px` first stop for a crisp center and `transparent` at 50% for soft falloff. The browser composites them in layer order. ```css background: radial-gradient(ellipse at 15% 20%, rgba(120, 40, 200, 0.9) 0px, transparent 60%), radial-gradient(circle at 80% 10%, rgba(40, 180, 220, 0.8) 0px, transparent 50%), radial-gradient(ellipse at 55% 75%, rgba(200, 60, 120, 0.85) 0px, transparent 55%), #1a0a2e; ``` For tinting — hover states, borders, soft fills — `color-mix()` handles it without any HSL arithmetic: ```css background-color: color-mix(in srgb, var(--accent) 12%, white); border-color: color-mix(in srgb, var(--accent) 25%, transparent); ``` One thing that cost me time: making these dynamic in Tailwind. A template literal like `` bg-[color-mix(in_srgb,${color}_12%,white)] `` silently produces nothing. Tailwind's compiler scans source files for complete static strings at build time. A class assembled from a variable doesn't exist as a string when the scanner runs — it gets skipped with no warning. Inline styles are the fallback for truly dynamic values. Text to Gradient is now an [npm package](https://www.npmjs.com/package/text-to-gradient). It powers the default cover images across the site when a page has no custom visual. Those covers are also animated — which is where the next package came from. ## Loopkit Every tool, blog post, landing page, and discovery step on varstatt.com has an animated SVG cover — all powered by [Loopkit](/toolkit/loopkit). I had ~35 cover designs already in JSX when I started building the engine underneath them. The first decision was whether to keep composable React components or switch to schema-driven JSON. JSON won because of output flexibility. A React component locks you into JSX. A schema is data — it can render to HTML for OG images, to SVG for exports, to CSS for emails, or to React for the live site. The core engine has no React dependency. ```javascript const cover = createCover(schema); cover.html // full HTML with inline styles cover.style // React style objects cover.innerHtml // just the elements cover.hoverCss // raw CSS rules ``` **Phase ordering.** I had the cycle structured as: animate forward, hold final frame, fade out, loop. Loop restarts were smooth, but the first `play()` call snapped instantly from the held frame to frame 0. Moving the fade to the beginning of the cycle fixed it — every iteration, including the first, starts with a reverse interpolation from wherever the animation sits, then plays forward. **Hover exits.** `mouseenter` called `play()`, `mouseleave` called `reset()`. The reset snapped to the static frame — functional but mechanical. A `settle()` method reads the live position and interpolates smoothly from there to the end state over a capped duration. The key: tracking `currentAnimElapsed` during active animation is what makes settle() possible. Without it, mouseleave can only snap. **Stagger math.** In a staggered loop where each element has its own delay, the cycle duration isn't `animDuration`. It's the time until the last element finishes, plus hold time. Using just `animDuration` cuts off late-starting elements before they complete. ```ts let lastFinish = 0; for (const el of schema.elements) { const delay = computeDelay(el.animate.sequence ?? 0, schema.stagger ?? 0); const duration = el.animate.duration ?? schema.duration ?? 1; lastFinish = Math.max(lastFinish, delay + duration); } const cycleDuration = lastFinish + holdDuration; ``` Re-centering all 48 schemas programmatically surfaced one more problem. The centering script computes a bounding box, then shifts coordinates to align with the canvas center. Loopkit schemas use `[from, to]` arrays for animated values — a bar animates with `y: [247, 87]`. The bbox script was reading `[0]`, the start value. A bar starting at y=247 with height 180 gave a 427px bounding box on a 280px canvas. The fix was one index: read `[1]`, the end state, because that's the visual rest position. Loopkit is under 5KB with zero dependencies. It's an [npm package](https://www.npmjs.com/package/loopkit) now. ## Markdown Repository [Markdown Repository](/toolkit/markdown-repository) began as a utility function inside this site. I query `.md` and `.mdx` files by frontmatter — filter by tags, sort by date, paginate. The API looks like Firestore's `where`/`orderBy`/`limit` chain. Once three of my projects used the same copy-pasted code, I extracted it into an [npm package](https://www.npmjs.com/package/markdown-repository). The publish pipeline — trusted publishing with OIDC, no stored tokens — turned into [its own post](/jurij/p/npm-trusted-publishing-from-github-actions). ## The Full List 45 tools, three npm packages. The full list is at [varstatt.com/toolkit](/toolkit). --- # https://varstatt.com/jurij/p/what-a-2-week-poc-looks-like --- title: What a 2-Week PoC Looks Like url: https://varstatt.com/jurij/p/what-a-2-week-poc-looks-like author: Jurij Tokarski date: 2026-04-07 description: Rapid prototyping service structured as a 2-week engagement. Validate the riskiest technical assumption, get a working prototype + go/no-go. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- A proof of concept is the fastest way to test whether an idea is technically buildable — a technical feasibility study run as working code, not slides. The retainer runs at $997/week. A PoC is two weeks — the shortest preset shape. Same retainer, agreed duration, specific outcome: [validate one technical assumption](/principles/discovery/find-the-core) before spending six weeks on an MVP. Most rapid prototyping services and prototype development service offerings build a demo of your full product vision. That feels productive but proves nothing. A business plan tells you what might work. A proof of concept [shows you what actually works](/principles/discovery/worth-building) — or doesn't. ## Throwaway Code Is the Feature Most rapid prototyping shops will tell you "the PoC code becomes the foundation of your product." It sounds responsible. It's also wrong, and it's the framing that makes most PoCs fail at the job they're meant to do. A proof of concept's job is to be wrong cheaply. It tests one specific assumption — real-time sync, AI cost at scale, payment routing — and answers it with working code that takes shortcuts everywhere it can. No auth, no UI polish, no test coverage. Just enough scaffolding to make the risky part real. The moment you start building a PoC that "could become the MVP," you've stopped doing a PoC. You're now doing an underbuilt MVP — slower, more expensive, and worse at the only question that mattered. You'll defend bad shortcuts because you don't want to throw them away. You'll spend two weeks proving nothing. I've written about this from the other direction in [If You Throw Away Your MVP Code, It Wasn't an MVP](/jurij/p/if-you-throw-away-your-mvp-code-it). MVP code stays — it becomes the product. PoC code is the opposite case: it goes in the bin once it's answered the question. That's not a failure mode. That's the design. What survives a PoC isn't code. It's the answer: build, pivot, or stop. ## What the Two Weeks Look Like Day 1 is technical discovery: mapping the business model, identifying the riskiest assumption, shaping the scope. The [Business Model Canvas](/discovery/business-model-canvas) and [Feature Prioritization](/discovery/feature-prioritization) tools compress what could be a week of back-and-forth into a single session. Days 2 through 8 are the focused build. Proof of concept software that tests the core hypothesis — no login screens, no dashboards. If the risk is real-time sync, we build real-time sync. If it's AI cost at scale, we instrument cost per call under realistic load. The final days are user testing, results review, and writing the recommendation. The deliverable is a clear go/no-go: continue to MVP, pivot, or stop. ## Proof of Concept vs Prototype vs MVP These three get mixed up constantly. The distinctions matter because they shape what you build and how much you spend. A **prototype** is a visual mock — clickable Figma, a static demo, sometimes a no-code build. It tests whether the idea makes sense to a user. No real backend. A **proof of concept** is working software that tests one specific technical question. Can this scale? Does the AI cost work out? Will the integration hold under load? It exists to answer a question that a Figma file cannot. An **MVP** [tests the market — will people pay](/jurij/p/how-do-you-know-if-your-idea-is-worth-building), do they return, does the growth loop work. It's the first real version of the product. Running them in the wrong order is expensive. [Spending $6K on an MVP only to discover the core technical assumption doesn't hold](/jurij/p/why-do-software-projects-fail) is a worse outcome than spending $2K on a PoC first. ## Who This Is For A founder with a specific technical risk — real-time sync, AI integration, payment complexity — who wants to validate technical feasibility before committing to a full build. Two weeks of focused proof of concept development costs $1,994 — the alternative is six weeks into an MVP build before realising the core assumption was wrong. An investor asking "can this actually be built?" who needs a credible answer, not a slide deck. A domain expert who wants to test before signing off on $50K of MVP development. In all three cases, the value is the same: replace a guess with a working result. If you have a technical assumption you're not confident in, the [business model canvas](/discovery/business-model-canvas) and [feature prioritization](/discovery/feature-prioritization) tools help frame the question before we talk. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a 6-Week MVP Build Looks Like](/jurij/p/what-a-6-week-mvp-build-looks-like)** — once the PoC validates the assumption, this is the build that follows - **[What a Fractional CTO Engagement Looks Like](/jurij/p/what-a-fractional-cto-engagement-looks-like)** — when the PoC is one early step in a longer founding-engineer arc - **[What a Code Audit Looks Like](/jurij/p/what-a-code-audit-looks-like)** — for a senior review on existing code before scoping the PoC - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/npm-trusted-publishing-from-github-actions --- title: npm Publish Without Tokens url: https://varstatt.com/jurij/p/npm-trusted-publishing-from-github-actions author: Jurij Tokarski date: 2026-04-07 description: Trusted publishing with OIDC replaces long-lived npm tokens. The setup has one undocumented requirement that returns a misleading 404. section: Blog (https://varstatt.com/jurij/archive) tags: software-delivery (https://varstatt.com/jurij/c/software-delivery) --- I published an npm package last week — [markdown-repository](https://www.npmjs.com/package/markdown-repository), a Firestore-style query builder for markdown files. The code worked. The tests passed. The release pipeline took longer to get right than the package itself. ## The Old Way The standard npm publishing workflow uses a long-lived access token. You generate it on npmjs.com, store it as a GitHub Actions secret, and reference it in your workflow: ```yaml - run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` It works, but the token never expires, has write access to your packages, and lives in plain text in your CI secrets. If it leaks — through a copied workflow file or a careless log — anyone can publish under your name. npm's granular tokens improved this slightly. You can scope them to specific packages and set a 90-day expiration. But you still have to rotate them manually. ## Trusted Publishing npm now supports [trusted publishing with OIDC](https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-trusted-publishing). Instead of a stored token, your GitHub Actions workflow proves its identity to npm using a short-lived OpenID Connect credential. npm verifies the credential against the workflow you've authorized, and accepts the publish. No token to store. No token to rotate. No token to leak. ## First Publish Is Manual Before you can configure trusted publishing, the package must already exist on the registry. npm has no "pending publisher" feature — you can't set up OIDC for a package that doesn't exist yet. For the very first version, publish from your machine: ```bash npm login npm publish --access public ``` I spent a while debugging my workflow before realizing trusted publishing only works from the second release onward. Once the package exists on npmjs.com, go to its settings and add a trusted publisher. From that point, the workflow handles everything. ## Setting Up the Workflow The setup has two parts. **On npmjs.com**: go to your package settings, add a trusted publisher. Specify the GitHub org/user, repository, workflow filename, and optionally an environment name. **In the workflow**: add `id-token: write` permission and an `environment` that matches what you configured on npm. ```yaml name: Release on: release: types: [published] permissions: contents: read id-token: write jobs: publish: runs-on: ubuntu-latest environment: release steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24.x registry-url: https://registry.npmjs.org cache: npm - run: npm ci - run: npm test - run: npm run build - run: npm publish --provenance --access public ``` Provenance attestation is automatic with trusted publishing. The `--provenance` flag is redundant but makes the intent explicit. ## The Misleading 404 My first three releases failed with this error: ``` npm error 404 Not Found - PUT https://registry.npmjs.org/markdown-repository npm error 404 'markdown-repository@1.1.0' is not in this registry. ``` The package existed. The version was correct. The OIDC token exchange succeeded — I could see the signed provenance statement in [Rekor's transparency log](https://search.sigstore.dev). Everything worked except the actual publish. The problem: **Node 22 ships with npm 10.x. Trusted publishing requires npm 11.5.1 or later.** npm's documentation mentions this requirement. The error message doesn't. A 404 on PUT looks like a registry problem or a package name conflict. Nothing points you toward an npm version mismatch. ## The Fix Use Node 24.x in your workflow. On GitHub Actions, `node-version: 24.x` resolves to a recent patch that includes npm 11.5.1+ — [markdown-repository](https://github.com/varstatt/markdown-repository/blob/main/.github/workflows/publish-package.yaml) publishes this way without an explicit npm upgrade. ```yaml - uses: actions/setup-node@v4 with: node-version: 24.x ``` If you're stuck on an older Node version, upgrade npm explicitly: ```yaml - run: npm install -g npm@latest ``` With npm 11.5.1+, the same workflow publishes successfully. No tokens needed. ## The Environment Mismatch The same 404 shows up when the **environment name** on npmjs.com doesn't match the `environment` field in your workflow job. If your workflow says `environment: release` but npm has the environment field blank (or vice versa), the OIDC claims don't match and npm rejects the publish — with a 404, not a meaningful error. ## What the Pipeline Looks Like Now The full workflow for [markdown-repository](https://github.com/varstatt/markdown-repository) runs lint, tests, and build on every commit. On a GitHub release, it publishes to npm with provenance — no secrets configured anywhere in the repository. If your CI/CD has shapes like this hiding in it — broken pipelines, secret sprawl, opaque failures — that's the kind of thing a [DevOps audit](/jurij/p/what-a-devops-audit-looks-like) is for. --- # https://varstatt.com/jurij/p/it-works-you-cant-ship-it --- title: It Works, But You Can't Ship It url: https://varstatt.com/jurij/p/it-works-you-cant-ship-it author: Jurij Tokarski date: 2026-04-03 description: Two AI providers fill the form correctly. Both route document data through global endpoints that don't meet every customer's residency policy. section: Blog (https://varstatt.com/jurij/archive) tags: ai (https://varstatt.com/jurij/c/ai), software-delivery (https://varstatt.com/jurij/c/software-delivery) --- Both providers work. Claude Sonnet 4.6 fills the government DOCX form on the first attempt — every field mapped, formatting preserved, no hallucinated values. After weeks of debugging the wrong architecture, the wrong upload method, and the wrong model versions, the technical problem is solved. The compliance problem hasn't started yet. The implementation uses sandboxed code execution — the file goes into a container, the model reads the OOXML structure, writes python-docx modifications, and saves a valid binary. Piecing together the right API calls took time, but the result is clean. GPT-5.4 on the public OpenAI API produces the same result independently. Different SDK, different container model, different file retrieval pattern — same filled form. Two providers, both working. The technical question is closed. ## The Question That Stops It The demo goes well. A client in the government-adjacent space watches the form fill in real time, checks the output, nods. Then someone from their compliance team asks: "Where does the data go when it hits that endpoint?" The question isn't hostile. It's procedural. They ask it about every external service. But for this feature, the answer creates a problem. This client runs on a regional cloud tenancy. Their data residency policy requires that document data stays within a specific jurisdiction. The AI feature routes their DOCX — containing names, addresses, case references — through an API endpoint outside their jurisdiction. No regional alternative exists for the sandbox infrastructure. ## The Infrastructure That Doesn't Exist I mapped the options after that meeting. **Anthropic code execution**: the direct API runs code execution sandboxes in the US only. Azure AI Foundry supports it in East US2 and Sweden Central. Google Vertex AI doesn't support code execution at all. None of these cover the APAC regions this client needs. **OpenAI code_interpreter**: same situation on the public API. Azure OpenAI technically supports the Responses API with code_interpreter, but the container infrastructure in non-US regions is unreliable — I hit timeout errors and empty responses testing on Azure that didn't reproduce on the public API. And the regions this client needs — Australia East, AP-Southeast — don't have the sandbox infrastructure deployed at all. **EU sovereign instances**: not available for either provider's code execution features. This isn't a configuration problem. There's no support ticket to file, no flag to set. The sandbox infrastructure that makes DOCX manipulation work does not exist in those regions today. Standard inference endpoints are available regionally — code execution sandboxes are not. ## The Question That Should Have Come First The data residency constraint wasn't hidden. It's in every provider's deployment documentation. Anthropic's data residency docs list US-only workspace geo for code execution. Azure AI Foundry adds Sweden Central but nothing in APAC. The Azure OpenAI docs list which features are available in which regions — code_interpreter coverage is sparse. The constraint was discoverable. It was in the documentation. It was not in my scoping conversation. If someone had asked "what cloud tenancy does this customer run on?" before I chose the code execution approach, the architecture decision would have been different. I might have scoped a local processing fallback. I might have flagged the feature as opt-in from the start. ## The Fork That Isn't Technical Two options, and one of them has to happen before the feature ships. **Option A**: ship the feature as opt-in. Surface a clear data-handling disclosure at the point of use — "this document will be processed via [provider]'s global API" — and let the customer's compliance team make the call. Some will accept it. Some won't. **Option B**: exclude customers under strict data-residency requirements entirely. Document why. Don't offer the feature in deployments where it can't meet the tenancy policy. Neither option is wrong. Both require a product decision that no amount of engineering can avoid. ## The Question That Comes First Next Time For any AI feature that routes documents through an external API, **ask the residency question in the first scoping conversation**. The capability is real — both providers fill the form correctly. The constraint is also real, and there's no workaround on the horizon. --- # https://varstatt.com/jurij/p/three-ways-the-wrong-value-won --- title: Three Ways the Wrong Value Won url: https://varstatt.com/jurij/p/three-ways-the-wrong-value-won author: Jurij Tokarski date: 2026-03-31 description: A race condition, a stale default, and a spread operator each delivered the wrong value to production. None threw an error. section: Blog (https://varstatt.com/jurij/archive) tags: debugging (https://varstatt.com/jurij/c/debugging), software-design (https://varstatt.com/jurij/c/software-design) --- A user created a tender and immediately couldn't edit it. Not after a day, not after some permission change — immediately. They hit "Create," the page loaded, and the edit button was grayed out. That was the first bug. It took three fixes across two projects before I understood what connected them: in each case, the value that reached the client wasn't the value I'd computed. Something else got there first — by being faster, by being stale, or by being last in the object literal. ## The Value That Arrived Too Early I pulled up the tender document in Firestore. The `ai_driver` field was missing entirely. The frontend created tenders like this: ```javascript const tenderData = { title, company_id: companyId, ...(companyData?.ai_driver && { ai_driver: companyData.ai_driver }), }; ``` New companies had no `ai_driver` set. The conditional spread evaluated to falsy, so the field was never written. That was supposed to be fine — a Cloud Function trigger would set the default after creation. The Firestore snapshot listener had other plans. It fired before the Cloud Function, saw no `ai_driver`, and ran this check: ```javascript const isDiscontinuedDriver = !tender.ai_driver || DISCONTINUED_AI_DRIVERS.includes(tender.ai_driver); ``` Missing field. Falsy. "Discontinued." Read-only. The user just watched their tender lock itself. Every single tender created by a new company since this code shipped had been born locked. The fix had two parts. The frontend writes every field it reads immediately after creation — no delegating defaults to triggers: ```javascript const tenderData = { title, company_id: companyId, ai_driver: companyData?.ai_driver || DEFAULT_AI_DRIVER, }; ``` And the discontinuation check had to distinguish "missing" from "actively deprecated": ```javascript const isDiscontinuedDriver = tender.ai_driver && DISCONTINUED_AI_DRIVERS.includes(tender.ai_driver); ``` Deployed both. Bug reports kept coming. ## The Value That Outlived Its Meaning Different users, same symptom. Tenders locked on creation. But these companies had `ai_driver` explicitly set in Firestore — set to `assistants-api-gpt4o`, a driver I'd discontinued months earlier. I traced it to the organization settings form: ```javascript aiDriver: company.ai_driver || "assistants-api-gpt4o", ``` That hardcoded fallback was a leftover from migration. New companies had no `ai_driver` in Firestore, so the form loaded with a dead value nobody could see. The field wasn't even visible on the settings page — it was an internal config, not a user-facing dropdown. The form submitted its entire state on every save. A user enables a jurisdiction toggle, hits save, and the payload includes `ai_driver: "assistants-api-gpt4o"`. The backend guard: ```javascript if (payload.ai_driver) { update.ai_driver = payload.ai_driver; } ``` Truthy string passes. The discontinued driver gets written to Firestore. Every tender created after that inherits it. The user who toggled a jurisdiction setting three weeks ago has no idea they just broke tender creation for their entire organization. I dropped the hardcoded fallback. Deployed. Reports kept coming — users had the old bundle cached. Every save from a cached session re-wrote the stale value, undoing any Firestore cleanup I ran manually. The frontend fix wasn't the real fix. The real fix was backend enum validation: ```typescript if (payload.ai_driver && Object.values(AIDriver).includes(payload.ai_driver)) { update.ai_driver = payload.ai_driver; } ``` The backend rejects any value not in the current enum. Cached bundles, stale defaults, garbage input — all dropped. The frontend can send whatever it wants; the backend is the last line, and it has to act like it. That stopped the bleeding. But the pattern was already in my head when I opened a different codebase weeks later. ## The Value That Was Always Last I was reviewing a feature flag called `ai_chat_enabled`. The backend computed it from the user's subscription plan — a careful if/else chain that looked up the plan, checked edge cases, and resolved to a boolean. Solid logic. Well-tested in isolation. Then I looked at the response builder: ```javascript return { statusCode: 200, body: { email: email_address, name: name, plan: plan, ai_chat_enabled: ai_chat_enabled, ...customerPreferences, }, }; ``` `customerPreferences` came from DynamoDB. It contained its own `ai_chat_enabled` key — the raw stored preference, not the computed one. The spread came after the explicit assignment. JavaScript object literals follow last-writer-wins. The spread silently overwrote the computed value with whatever was sitting in the database. The entire plan-based computation — the lookup, the edge cases, the if/else chain — never reached the client. Not once. Not since the day this code shipped. The tests checked that the computation logic returned the right boolean. They never checked that the response builder actually used it. The fix was one line — move the spread before the explicit fields: ```javascript return { statusCode: 200, body: { ...customerPreferences, email: email_address, name: name, plan: plan, ai_chat_enabled: ai_chat_enabled, }, }; ``` Computed values last. Raw data first. The spread provides defaults; the explicit fields override them. ## The Wrong Value Always Has a Way In Timing, staleness, ordering. Three mechanisms, same result: the value I intended never made it. If the frontend reads a field, the backend must validate it. If the backend computes a value, nothing downstream should be able to quietly replace it. The wrong value will always find a way in. The only defense is making sure the right value goes last. --- # https://varstatt.com/jurij/p/silent-ai-response-corrupted-conversation-history --- title: An Empty AI Response Corrupted Chat History url: https://varstatt.com/jurij/p/silent-ai-response-corrupted-conversation-history author: Jurij Tokarski date: 2026-03-26 description: Gemini returned HTTP 200 with zero content. I saved the empty response to conversation history. The chat never recovered. Here's what went wrong. section: Blog (https://varstatt.com/jurij/archive) tags: debugging (https://varstatt.com/jurij/c/debugging), ai (https://varstatt.com/jurij/c/ai) --- The spinner ran. The stream closed. The chat bubble stayed empty. No error anywhere. I was building a conversational discovery tool for founders — a multi-step Gemini-powered flow that walked people through product decisions, collected answers, and built a structured brief. Complex setup: long system prompt, tool definitions, large user messages. Genkit's `generateStream` handling each turn. Intermittently, a user would send a message and get nothing back. No timeout, no catch block firing, no non-2xx status. Just a clean stream completion with zero content inside. ## What the Logs Said When I Added Them Standard error handling gives you no signal here: ```javascript try { const { stream, response } = await ai.generateStream({ ... }); for await (const chunk of stream) { // exits immediately — no chunks arrive } // response.text() returns '' // no exception thrown } catch (err) { // never reached } ``` Adding chunk-level logging made it visible. The stream was completing, but the one chunk that arrived looked like this: ``` Chunk #1 has no content. Keys: [ 'index', 'role', 'content', 'custom', 'previousChunks', 'parser' ] role: model content.length: 0 ``` The `content` property existed. It wasn't null. It was an empty array. The keys `custom`, `previousChunks`, and `parser` are Genkit's internal markers for a thinking chunk. The model had spent the entire response budget on internal reasoning and had nothing left to output. HTTP 200. Genkit reported success. ## Two Ways to Get Nothing Gemini 2.5 Flash ships with thinking mode enabled by default. Under normal inputs that's fine. Under heavy inputs — long system prompt plus tool definitions plus a long user message — it can exhaust the entire token budget on reasoning before producing a single output token. There's a second cause that produces the same result: silent rate limiting. Rather than returning a 4xx, Gemini returns a valid, complete, empty stream. The observable symptom is identical. The detection is identical: assert that at least one content chunk arrived after the stream closes. For the thinking mode case, the fix is one line in the Genkit config: ```javascript const { stream, response } = ai.generateStream({ model: MODEL, system: systemPrompt, messages, tools, config: { thinkingConfig: { thinkingBudget: 0 }, }, }); ``` `thinkingBudget: 0` disables extended thinking. For a conversational flow where latency matters more than deep reasoning, there's no reason to let the model spend the budget on internal traces. Fix deployed. I moved on. ## The Save That Made It Permanent What I hadn't checked: the database. Every one of those empty responses had already been saved to Firestore. An empty string is a valid string. The save ran. Nothing flagged it. The stream handler read `finalResult.text` after `generateStream` resolved and wrote it as the AI's message. When thinking mode ate the budget, `finalResult.text` was `""`. Firestore now held a record of every affected conversation — each one storing a legitimate-looking AI turn with no content. ## History as Poison When those users came back and sent new messages, `getChatHistory` pulled their messages from Firestore and formatted them for Gemini: ```javascript return messages.map((msg) => ({ role: msg.role === "ai" ? "model" : "user", content: [{ text: msg.content }], })); ``` When `msg.content` is `""`, that produces `{ role: "model", content: [{ text: "" }] }`. A valid-looking empty model turn in the middle of a real conversation. Gemini received it, interpreted it as unfinished context, entered thinking mode to reason about it, exhausted the budget, returned nothing — which got saved as another empty message, which poisoned the next turn. The conversation was permanently, silently broken. No exception at any layer. No signal the user could act on. Just a chat that would never respond again. ## The Fix That Requires Two Places Fixing only the stream detection isn't enough — the database is already corrupted. Fixing only the history filter isn't enough — new empty responses can still arrive and be saved. Both defenses are required. Never write an empty AI message: ```javascript const finalText = accumulatedText || finalResult.text || ""; if (finalText) { await saveAIMessage(chatId, finalText); } else { console.warn("[StreamHandler] Skipping empty AI message save"); } ``` And filter empty turns before sending history to the model: ```javascript return messages .filter((msg) => msg.content) .map((msg) => ({ role: msg.role === "ai" ? "model" : "user", content: [{ text: msg.content }], })); ``` Miss either one and the loop can restart. The stream guard stops new corruption. The history filter handles the records already in the database. ## The Retry That Made It Worse The first instinct after detecting an empty stream was to retry. The naive retry called the same send function — which re-inserted the user's message into the messages array. The model received the question twice. On an already-stressed conversation with heavy context, this accelerated the problem rather than resolving it. The fix is an `isRetry` flag that skips message insertion on retry calls: ```javascript async function streamMessage(content, sessionId, token, { isRetry = false } = {}) { if (!isRetry) { setChatMessages(prev => [ ...prev, { id: userMsgId, role: 'user', content }, { id: aiMsgId, role: 'assistant', content: '' }, ]); } else { setChatMessages(prev => [ ...prev.filter(m => m.id !== aiMsgId), { id: aiMsgId, role: 'assistant', content: '' }, ]); } await streamAIResponse(sessionId, token); } ``` The user message stays in history exactly once. Without this, retry logic breaks an already-broken conversation faster. ## Why Every Layer Said "Success" What made this hard to debug: every layer reported success. HTTP 200, no caught exceptions, valid Firestore writes, clean history formatting. The failure was in the semantics, not the mechanics. An empty model turn is not a successful model turn — and asserting that distinction at each boundary is the only thing that stops the loop. If your AI feature has classes of failure like this hiding in it — silent errors, semantic bugs, "everything looks fine" regressions — that's the kind of thing a [code audit](/jurij/p/what-a-code-audit-looks-like) is for. --- # https://varstatt.com/jurij/p/how-do-you-know-if-your-idea-is-worth-building --- title: How do you know if your idea is worth building? url: https://varstatt.com/jurij/p/how-do-you-know-if-your-idea-is-worth-building author: Jurij Tokarski date: 2026-03-25 description: The discovery questions that separate good ideas from projects destined to fail. Demand validation, core features, and appetite-based scoping. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Most founders build before they ask. They get excited, set a timeline, and start coding. But if you haven't found real demand yet, you're just hoping the market agrees with you. The first discovery filter is brutal and simple: can you find ten people in a week who genuinely have the problem you're solving? Not people being nice. Not your assumptions. People who'd actually pay money because they need this. If you can't find them, the idea might be real someday, but not now. The market's telling you something — listen. The second gate is defining your [core feature](/discovery/feature-priorities) in one sentence. Can you explain what you're building, the single most valuable thing, without qualifiers or side-features? Most founders realize at this point that what they wanted to build isn't the real problem. That clarity is gold. Sometimes an idea isn't worth building right now. Maybe the timing's off, or you don't have the right team, or the market conditions aren't aligned. Discovery forces that conversation before you've burned weeks building the wrong thing. The full framework is in the [Discovery principles](/principles/discovery). --- # https://varstatt.com/jurij/p/why-software-is-a-business-cost --- title: Why should you think of software as a business cost, not a craft? url: https://varstatt.com/jurij/p/why-software-is-a-business-cost author: Jurij Tokarski date: 2026-03-24 description: Reframing software development from an art form to a predictable business expense changes how you build. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Most software teams talk about their work like artists in a studio — tweaking, perfecting, exploring the elegant solution. But that mindset is why projects balloon in cost and scope. Software should be treated as a business cost: a predictable, measurable expense that delivers specific business value, not a creative endeavor. When you think of software as a cost, you optimize for different things. You stop chasing technical elegance for its own sake. You stop adding features that seem cool but don't solve the problem. You focus on the simplest path to shipping something that works and generates return on investment. This doesn't mean the work is sloppy or unmaintainable. It means you're honest about trade-offs. You use boring, proven technologies instead of experimenting with the latest framework. You ship working software in weeks, not months. You measure success by whether it solves the business problem, not by how clever the code is. The practical upside is that your projects stay on budget and on timeline. Your team moves faster because you're not debating architectural purity. Your clients get value sooner. When you treat software as a cost to be managed rather than a craft to be perfected, everything gets simpler. --- # https://varstatt.com/jurij/p/what-a-devops-audit-looks-like --- title: What a DevOps Audit Looks Like url: https://varstatt.com/jurij/p/what-a-devops-audit-looks-like author: Jurij Tokarski date: 2026-03-24 description: DevOps audit in the original sense — how your team ships software. CI/CD, deployment, rollback, monitoring. Not cloud administration. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- Most teams set up CI/CD once and never revisit it. The pipeline that worked for three developers and weekly deploys starts choking at ten developers and daily releases. Builds slow down. Flaky tests get ignored. Deployments become a ritual that only one person fully understands. That's not a sign you need more infrastructure. It's a sign the infrastructure you have hasn't kept up with how the team works now. ## DevOps as a Mindset, Not a Job Title "DevOps" originally meant a way of working — fast, safe deploys, shared ownership between developers and operations, continuous flow over big batches, blameless culture, monitoring as a first-class concern. Patrick Debois coined the term in 2009. The Allspaw and Hammond talk from the same year ("10+ Deploys Per Day") is the canonical reference. Around 2015 the term got captured by job titles. Today "DevOps Engineer" usually means cloud administrator — someone who runs Kubernetes clusters, writes Terraform, designs VPCs, manages AWS IAM. Useful work, but not what Debois meant. This audit is DevOps in the original sense. It looks at how your team actually ships software: the deploy pipeline, the rollback story, the gap between staging and production, the monitoring that catches problems before users do. It doesn't redesign your VPC or rewrite your IAM policies — that's cloud engineering, a different specialty. If your real bottleneck is multi-account AWS governance, you need a cloud engineer, not me, and I'll say so on the discovery call. For most product teams under ten people, the bottleneck isn't cloud architecture. It's the deploy pipeline, the broken staging environment, and [the deploy ritual only one person fully understands](/principles/diligence/documentation). That's what this audit fixes. ## The Shape One week, $997, fixed scope. [Most DevOps consultant engagements are open-ended monthly retainers](/jurij/p/whats-the-difference-between-freelancer-agency-and-retainer). This one isn't — one focused week of review, then a prioritized list of improvements with effort estimates so you know what to fix first and what to ignore for now. ## What Gets Reviewed **Build pipeline.** How long do builds take? Where's the time going? Unnecessary steps, missing caching, parallelization that was never set up. The kind of thing a CI/CD consultant would tackle in an ongoing engagement, compressed into days. A CI/CD audit typically finds at least a few minutes of savings on most pipelines — which compounds when you're deploying ten times a day. **Deployment strategy.** Push-and-pray is a strategy. So is blue-green, rolling, and canary. Most teams are somewhere in between: partially automated, partially manual, with a fragile handoff. The [deployment pipeline review](/jurij/p/streamlining-software-release-process) surfaces the fragile parts. **Rollback capability.** Can you undo a bad deploy in under two minutes? If the answer is "it depends" or "we'd have to call someone," that's a gap. Fast rollback is what makes [continuous flow](/principles/delivery/continuous-flow) safe. **Environment parity.** Staging that doesn't match production isn't staging — it's a false signal. Configuration drift, secret management, whether the environments catch problems before they reach users. **Hosting setup and cost.** On Vercel, Firebase Hosting, Render, Fly.io, or Railway, I check whether the project is configured sensibly — build settings, function limits, region choices, billing inefficiencies. On AWS or GCP, I'll review application-side configuration; redesigning a VPC or IAM model belongs to a cloud engineer. A GitHub Actions audit often turns up redundant workflow runs burning minutes for no reason. **Monitoring and logging.** Monitoring that [catches problems before users report them](/jurij/p/production-bugs-that-never-threw-an-error) looks different from monitoring that generates noise. The review checks what's alerting, what's not, and whether logs help debug or just drown you in output. [Monitor Day One](/principles/diligence/monitor-day-one) is the principle. **Application-side security gaps.** Exposed secrets in client bundles, env variables in git, missing [quality gates](/principles/delivery/quality-gates) on dependency checks, deploy-pipeline misconfigurations that leak credentials. Not a full security audit — just the gaps that show up consistently in pipeline review. ## A Note on Complexity If your team is under ten people, you probably don't need Kubernetes. Most teams that reach for it are [optimizing for a scale problem they don't have yet](/jurij/p/when-optimization-culture-breaks), while ignoring deploy problems they have right now. An infrastructure audit doesn't have to recommend more infrastructure — usually the opposite. ## What Comes Out A prioritized improvement list. Half-day fixes, longer projects, separated clearly. The [incident response](/principles/diligence/incident-response) gaps tend to be quick wins; architecture changes are longer bets. If the team is doing manual deploys or working off fragile shell scripts, the audit usually produces a concrete path to something more reliable — proper GitHub Actions, deployment checklists, or a [feature flag](/principles/delivery/feature-flags) layer that lets you ship without holding your breath. ## Who This Is For Startups whose deploy process hasn't kept up with team growth. Teams deploying manually or with fragile scripts. Products where downtime directly costs revenue. [Engineering leads who want a deploy pipeline they trust](/principles/delivery/production-is-done) — the audit won't get you SOC 2, but it removes the operational chaos that makes compliance harder than it needs to be. If your deployment process is something your team works around rather than relies on, this is the audit. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a Code Audit Looks Like](/jurij/p/what-a-code-audit-looks-like)** — broader scan covering architecture, security, performance, and tech debt - **[What a Firebase Audit Looks Like](/jurij/p/what-a-firebase-audit-looks-like)** — Firebase-specific deep-dive on Firestore, security rules, billing - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — once the pipeline's fixed, keep it that way - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/software-engineering-principles-for-startups --- title: Software Engineering Principles for Startups url: https://varstatt.com/jurij/p/software-engineering-principles-for-startups author: Jurij Tokarski date: 2026-03-23 description: 39 principles I use to ship software every week: a working system built from years of product development section: Blog (https://varstatt.com/jurij/archive) tags: software-delivery (https://varstatt.com/jurij/c/software-delivery) --- Most software engineering principles are written for teams of 50. Agile ceremonies, sprint retrospectives, quarterly planning — built for organizations, not for founders shipping products. I run a solo development studio. I ship to production every week, manage multiple client projects simultaneously, and maintain everything I build. Over the years I wrote down the principles that make this work. There are [39 of them](https://varstatt.com/principles), organized across five areas: philosophy, discovery, delivery, partnership, and diligence. Here's what actually matters when you're building software for startups. ## Start With What's Worth Building The most expensive software is software that shouldn't exist. Before writing any code, I run every project through a simple filter: [is this worth building?](https://varstatt.com/principles/discovery/worth-building) Most ideas aren't. Not because they're bad ideas — but because they solve the wrong problem, or solve it at the wrong time, or solve it for a market that doesn't care enough to pay. When something passes that filter, the next step is [finding the core](https://varstatt.com/principles/discovery/find-the-core) — the one capability that makes this product exist. Not the feature list. Not the competitor parity matrix. The single thing that, if it doesn't work, means nothing else matters. Jane's booking app needed staff-to-service matching that handled real salon complexity. Everything else — payment processing, notifications, calendar sync — is infrastructure you can buy. The core is the only part worth building custom. ## Fix the Budget, Flex the Scope Startups don't have unlimited time or money. The traditional approach — estimate everything, add buffer, hope it fits — doesn't work because estimates are wrong. I use [appetite, not estimates](https://varstatt.com/principles/discovery/appetite-not-estimates). You decide how much time a problem is worth — two weeks, six weeks — and that's your constraint. Then [scope shaping](https://varstatt.com/principles/discovery/scope-shaping) fits what you build inside that box. This sounds backwards but it changes everything. Instead of "how long will this take?" the question becomes "what's the best version we can ship in three weeks?" That question has a useful answer. ## Ship Continuously, Not Eventually Startup velocity comes from short feedback loops. Every principle in my [delivery system](https://varstatt.com/principles/delivery) optimizes for one thing: getting working software in front of users faster. [WIP One](https://varstatt.com/principles/delivery/wip-one) means one task in progress at a time. Finish it, deploy it, move on. Context switching kills solo developers faster than bad architecture. [Production is done](https://varstatt.com/principles/delivery/production-is-done) means nothing counts until it's live. Not "done on my machine." Not "ready for review." Live in production with monitoring in place. This sounds obvious but most projects have weeks of "almost done" work that never ships. [Continuous flow](https://varstatt.com/principles/delivery/continuous-flow) replaces sprints with a priority queue. No sprint planning, no velocity tracking, no ceremony. Just: what's most important right now? Do that. Deploy it. For startup teams, this means you can change direction on Monday and ship the new thing by Wednesday. No "we'll add it to next sprint." ## Software Development Is a Cost, Not a Craft This is the one that makes developers uncomfortable: [software development is a business cost](https://varstatt.com/principles/philosophy/business-cost). It's an operational expense, like rent or hosting. That doesn't mean quality doesn't matter. It means quality serves the business, not the developer's ego. The [scout rule](https://varstatt.com/principles/delivery/scout-rule) — leave the codebase better than you found it — keeps quality high without separate "refactoring sprints" that never get prioritized. [Consolidation](https://varstatt.com/principles/philosophy/consolidation) means fewer tools, fewer vendors, fewer moving parts. Every additional service is another bill, another dashboard, another thing that breaks at 2 AM. For startups, simplicity is a feature. ## Build the Boring Parts Last [Context over purity](https://varstatt.com/principles/discovery/context-over-purity) means making pragmatic decisions, not architecturally perfect ones. Use the default stack. Buy what you can. Build only what's core. I keep a [default stack](https://varstatt.com/principles/delivery/default-stack) and use it for everything unless there's a specific reason not to. Deep expertise in familiar tools beats starting fresh with the "best" technology for each project. When a client asks "should we use microservices?" the answer is almost always no. Not because microservices are bad — because for a startup, a monolith you ship in three weeks beats a distributed system you ship in three months. ## Transparency Over Everything Startup partnerships fail on misaligned expectations, not technical problems. Every [partnership principle](https://varstatt.com/principles/partnership) I follow addresses this directly. [Transparency](https://varstatt.com/principles/partnership/transparency) means full visibility into progress, problems, and decisions. No weekly status reports that hide bad news. When something goes wrong — and it will — the client knows the same day. [Weekly accountability](https://varstatt.com/principles/partnership/weekly-accountability) creates a billing cycle that forces honest conversations. If the week didn't produce visible progress, that's a problem we discuss before the next week starts. [Exit freedom](https://varstatt.com/principles/partnership/exit-freedom) means clients can leave at any time. No contracts, no lock-in, no hard feelings. If the work isn't valuable, you should be able to stop paying for it immediately. This keeps me accountable in a way that six-month contracts never could. ## Maintenance Is Not a Phase The biggest lie in software development: "We'll build it, launch it, then maintain it." As if building and maintaining are separate activities. [No split](https://varstatt.com/principles/diligence/no-split) means development and maintenance happen continuously. Every feature I ship includes monitoring. Every deployment includes the ability to roll back. [Quality gates](https://varstatt.com/principles/delivery/quality-gates) and [feature flags](https://varstatt.com/principles/delivery/feature-flags) make it safe to fail and fast to fix. For startups, this means you don't need a separate "operations team" from day one. The development process IS the operations process. Ship code, watch it run, fix what breaks, improve what works. ## The Full System These principles aren't independent tips — they form a system. Discovery principles prevent you from building the wrong thing. Delivery principles get the right thing shipped fast. Partnership principles keep everyone aligned. Diligence principles make sure it keeps working. I documented all [39 principles](https://varstatt.com/principles) as a reference — you can also [ask the handbook directly](/principles/chat) — not as rules to follow blindly, but as a starting point for founders who want their engineering process to actually work. The best engineering principles for your startup are the ones that let you ship every week. Everything else is overhead. > Live: [varstatt.com/principles](https://varstatt.com/principles) --- # https://varstatt.com/jurij/p/why-weekly-retainers-work-better-than-sprints --- title: Why Varstatt Uses Weekly Retainers, Not Sprints url: https://varstatt.com/jurij/p/why-weekly-retainers-work-better-than-sprints author: Jurij Tokarski date: 2026-03-22 description: How continuous priority queue work differs from sprint-based development. Weekly retainers create accountability without the ceremony of sprints. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Weekly billing changes how work actually flows. Instead of planning two-week or three-week sprints, work comes in, gets prioritized, gets executed, then ships. There's no artificial boundary where you're waiting for the next sprint to start or cramming things into an arbitrary cycle. The rhythm matches reality instead of a calendar. This matters for responsiveness. Something urgent mid-week doesn't wait for sprint planning next Monday—it gets added to the queue and surfaces based on priority. Clients see faster iteration and I see clearer signal about what actually matters. You're not negotiating scope within a time box; you're managing a continuous queue of ranked work. It also makes pausing honest. Since billing is weekly and work is continuous, a client can pause for a month or cancel entirely without renegotiating contracts. Billing stops immediately. This removes the awkwardness of "we want to pause but you'll charge us through the sprint end," and it means clients don't pad scope to feel like they got their money's worth. The trade-off is accountability on both sides. The client has to maintain the queue and prioritize clearly. I have to be ready to shift context if priorities move. It's not great for teams that need planning certainty or "predict and deliver" contracts, but for ownership and rapid iteration, it's cleaner than sprints. --- # https://varstatt.com/jurij/p/why-scrum-fails-in-small-teams --- title: Why Scrum Fails In Small Teams url: https://varstatt.com/jurij/p/why-scrum-fails-in-small-teams author: Jurij Tokarski date: 2026-03-21 description: Scrum was designed to coordinate large cross-functional teams. When your team is small enough to just talk, the ceremonies become the bottleneck. section: Blog (https://varstatt.com/jurij/archive) tags: software-delivery (https://varstatt.com/jurij/c/software-delivery) --- A few years ago, my development team of three was sitting through a 90-minute sprint planning ceremony. The feature we planned took two days to build. We spent more time estimating and discussing the work than doing it. I was the team lead, and this was the moment I started questioning what we were actually doing here. ## Scrum Solved a Real Problem — Then Became One Scrum is a project management framework built around fixed-length iterations called sprints — usually two weeks. Each sprint has a planning ceremony, daily standups, a review, and a retrospective. There's a product owner who manages the backlog, a scrum master who facilitates the process, and a development team that executes. It was created in the 1990s to bring structure to software projects that were failing under waterfall — the old approach of planning everything upfront, building for months, and hoping the result matched reality. Scrum introduced short feedback cycles. Ship something every two weeks. Inspect and adapt. That was genuinely better than what came before. The agile manifesto that underpins scrum development prioritizes individuals over processes, working software over documentation, customer collaboration over contracts, and responding to change over following a plan. Good principles. The problem is what the industry built on top of them. ## Sprint Boundaries Are Artificial Tasks don't fit neatly into two-week boxes. Some take three days. Some take twelve. Forcing them into fixed time boundaries creates two failure modes: you either pad estimates to fill the sprint, or you rush to hit an arbitrary deadline that has nothing to do with the actual complexity. When a [priority shifts mid-sprint](/principles/partnership/priorities-not-scope), scrum says wait until the next planning ceremony. In a small team, that's absurd. The client calls, explains why Feature B is now urgent, and you should be able to switch today — not in nine days when the sprint ends. ## Velocity Tracking Becomes Theater Story points were meant to help teams estimate work. In practice, they become a performance metric. Teams optimize for point throughput instead of actual value delivered. A refactoring task that prevents six months of tech debt gets 2 points. A trivial UI change that the PM can demo gets 8. When one person does the work, velocity tracking is particularly absurd. You already know your throughput. You lived it yesterday. ## Ceremonies Replace Communication Daily standups. Sprint planning. Sprint review. Sprint retrospective. Backlog grooming. For a team of fifteen with cross-functional dependencies, these rituals serve a real purpose — they force information sharing that wouldn't happen naturally. For a team of three? Or a solo developer working with a client? These meetings replace the actual communication they were designed to facilitate. You don't need a standup when you can send an async update after each work session. You don't need sprint planning when the priority queue is a shared list that either side can reorder at any time. When the framework produces more Jira tickets, confluence pages, and status updates than actual shipped code, something has gone wrong. The best process is invisible — it stays out of the way while work gets done. ## Every Time I Switched to Kanban, Delivery Rocketed I've led dev teams twice. Both times we started with scrum because that's what the organization used. Both times we shifted toward kanban. And both times the same thing happened: delivery rocketed and people became happier. The only meeting that survived was a real daily standup — five minutes to talk about blockers and maybe share plans. That's it. The entire status was visible on the Jira board. Anyone could look at it anytime. No ceremony needed to extract information that was already public. I've shipped software since 2011. Now I run my own practice based on [continuous flow](/principles/delivery/continuous-flow) — Kanban, not Scrum. Here's how it works: ## A Priority Queue, Not a Sprint Backlog The client maintains a ranked list. The top item is the highest priority. I work top-down: finish what's in front, then pull the next thing. Priorities shift? The client reorders the list. No replanning ceremony. No negotiating what fits in the sprint. The developer is always working on what matters most right now. ## One Thing at a Time, Then Ship It [One task at a time](/principles/delivery/wip-one). Finish it. Deploy it. Then move on. This forces honest prioritization and kills context switching. It prevents the trap of being "90% done on five things" while nothing is actually working. Code review isn't done. QA passed isn't done. Merged isn't done. [Working in production is done](/principles/delivery/production-is-done). This changes how you think about deployment. If deploying is hard, it gets avoided. If it's easy, it happens constantly. Feature flags handle incomplete work — deploy behind the flag, keep building, flip it when it's ready. ## Async Updates Beat Standups Updates go out after each work session — not at end of day, not at a standup, but when the work is actually done. Meetings happen only for decisions that genuinely need real-time discussion. Everything else is written. This keeps calendars empty and [focus time protected](/principles/partnership/async-first). For significant features, I think in six-week cycles — long enough to deliver something end-to-end valuable, short enough to stay honest. A cycle isn't a deadline. It's a planning horizon. "In six weeks, we expect X to be working." The cycle serves orientation, not ceremony. ## For Big Orgs, Scrum Is Still Revolutionary I'm not anti-process. I'm anti-unnecessary-process. For old-school corporations that have been running waterfall for decades, scrum is genuinely revolutionary. It introduces feedback loops, iterative delivery, and customer involvement where none existed before. That's a massive upgrade. If scrum is moving your 200-person org from annual releases to biweekly ones — keep going. That's real progress. Scrum works when you have large teams with cross-functional dependencies, regulated environments where audit trails are compliance requirements, organizations that need guardrails to prevent chaos, or teams coming from waterfall who need a stepping stone. But your dev team of four is probably shooting itself in the foot with this. ## Small Is a Strength, Not a Problem to Fix Here's what I see constantly: small teams and startups adopting processes designed for organizations ten times their size. Scrum is one of those processes. So are SAFe, detailed PRDs, elaborate RACI matrices, and weekly all-hands with thirty-slide decks. It comes from the same instinct — wanting to look and feel like a "real" company. But it's backwards. Being small is not a weakness to compensate for. It's an advantage to exploit. A team of four can make a decision in a Slack thread that would take a 40-person team two sprint ceremonies and a steering committee. You can deploy a hotfix in twenty minutes while a large org is still scheduling the incident review. You can pivot your roadmap over lunch. My advice: use the strength you actually have. You're small, so act quick. Don't import the overhead of organizations that would kill to have your agility. ## The Best Process Disappears The agile manifesto got it right: individuals and interactions over processes and tools. Somewhere along the way, the industry built an entire certification industry, a tooling ecosystem, and a consulting practice around processes and tools. The best development process is the one you don't notice. Work comes in, gets prioritized, gets built, gets shipped. No theater. No rituals that exist to feel productive rather than be productive. Build it. Deploy it. Get feedback. Pull the next priority. --- # https://varstatt.com/jurij/p/what-an-automation-audit-looks-like --- title: What an Automation Audit Looks Like url: https://varstatt.com/jurij/p/what-an-automation-audit-looks-like author: Jurij Tokarski date: 2026-03-21 description: Workflow automation service review in one week. Map internal portals, admin panels, and n8n / Make workflows — what you have, what's broken, what to consolidate. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- An automation audit is one week, $997, fixed deliverable: review every internal tool and workflow, trace each end-to-end, hand back a prioritized plan. Fix, consolidate, automate, semi-automate, or remove. One week is all it takes to surface what's actually going on. The hard part isn't the audit — it's accepting what it finds. ## Most Automation Doesn't Pay Off Most businesses think about automation as a binary: this process is either automated or it isn't. The "automate everything" crowd pushes for the first state — every step end-to-end, no humans in the loop. The "we'll just keep doing it manually" crowd defends the second. Both are usually wrong. The honest answer most teams don't want to hear: a meaningful share of the automations I find should be deleted, not optimized. [Workflows that fire correctly but produce output nobody reads](/principles/diligence/observe-improve). Sync jobs between systems that no longer need to be in sync. Notification triggers from a tool the team stopped using eight months ago. Automation isn't free — it has maintenance cost, surface area for breakage, and cognitive overhead for whoever inherits it. The other thing most teams underrate: the [**semi-automated, human-in-the-loop** approach](/jurij/p/workflow-automation-is-business-superpower). Solve 80% of the problem with a workflow that does the boring parts — collect, normalise, format, draft. Leave the last 20% — the part that needs judgment or exception handling — to a human who reviews and approves. You get most of the speed gain, almost none of the failure modes, and a system that degrades gracefully when something upstream changes. People skip this option because it doesn't sound impressive. "Half-automated" feels like a half-measure. It isn't. Full automation is the right call when work is genuinely high-volume, low-judgment, and well-defined. Most internal processes don't qualify on all three. The audit doesn't optimize for "automate more." It [optimizes for the right shape per workflow](/principles/discovery/find-the-core): full automation where it earns its keep, human-in-the-loop where judgment matters, manual where volume doesn't justify maintenance, and deletion where nothing of value would be lost. ## The Tool That Became Infrastructure Most businesses have one. An **admin panel built three years ago** as a stopgap that now runs half of operations. An internal portal nobody updates that still generates the reports the team relies on. A cron job added to paper over a data sync issue that never got properly fixed. These don't feel like technical debt when they're working. They feel like the job — until the person who knows them leaves, or a vendor changes an API, or you want to add a workflow and discover you can't without touching something fragile. The [cost of not addressing it](/principles/philosophy/business-cost) compounds quietly until it doesn't. ## What the Audit Covers Every internal portal and tool gets reviewed: maintenance burden, training overhead, data integrity, opportunity cost of consolidation. The **workflow automation layer** gets the same treatment. n8n workflows, Make scenarios, Zapier automations already in place, scheduled processes and cron jobs. Error rates, redundant triggers, data flow integrity. Most businesses running a workflow automation service for more than a year have accumulated sequences that overlap, fire in the wrong order, or silently fail without alerting anyone. This is the layer an n8n workflow review or Make.com consultant engagement usually starts with — mapping what actually exists before recommending what to change. Rebuild work, when the audit finds it's needed, happens in n8n or Make — not Zapier. The [crontab tool](/toolkit/crontab) is useful for sanity-checking schedule expressions during the review. ## What You Get A prioritized plan — each item tagged as fix, consolidate, automate, semi-automate, or remove. Every item includes effort estimate and expected impact. In most audits, "remove" and "semi-automate" together account for more items than "automate." That's what an honest review tends to find. [Consolidation](/principles/philosophy/consolidation) is the underlying principle — fewer, better tools instead of more layered on top of each other. The plan is ordered by impact. You leave with a clear picture of what to do now, what can wait, and what to stop paying for. The audit is the plan, not the implementation. Execution is a separate engagement, scoped based on what surfaces. The [simple request that uncovered something bigger](/jurij/p/simple-e-commerce-request-uncovered) is a common pattern the audit is designed to surface before it becomes an expensive surprise. ## Who Needs This Businesses with internal portals built by previous developers, where [the original developer is gone and nobody's confident](/principles/diligence/documentation) about what the system does. Operations teams where manual processes persist despite existing tooling. Companies considering new back-office investment but unsure what they already have. If your internal tooling feels harder to understand than it should, or automation has grown without anyone keeping track, this is the audit. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a Code Audit Looks Like](/jurij/p/what-a-code-audit-looks-like)** — broader scan when the issue isn't just automation but the whole codebase - **[What a Fractional CTO Engagement Looks Like](/jurij/p/what-a-fractional-cto-engagement-looks-like)** — when "we have no idea what we're running" calls for ongoing senior eyes - **[What a Software Maintenance Retainer Looks Like](/jurij/p/what-a-software-maintenance-retainer-looks-like)** — once the audit recommendations land, keep workflows from regressing - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/why-do-software-projects-fail --- title: Why do software projects fail? url: https://varstatt.com/jurij/p/why-do-software-projects-fail author: Jurij Tokarski date: 2026-03-20 description: The root causes of scope creep, budget overruns, and misalignment that waste time and money. What goes wrong and how discovery prevents it. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Most projects I've seen fail because nobody actually agrees on what needs to be built. You're thinking one thing, the developer's thinking another, and by the time anyone notices the gap, you've burned through budget and time chasing the wrong solution. Then scope creeps in, not because developers add features intentionally, but because requirements emerge during development instead of upfront. A client asks for "one small thing" in week three, then week five, and suddenly the budget and timeline have doubled. The real issue is that client and developer aren't actually partnering. One side executes what they were told without asking whether it's solving the right problem. You ship something that works technically but solves the wrong thing. I've watched this happen for over a decade, which is why these principles exist. They cover [discovery](/discovery) (getting alignment before code), delivery (shipping often to catch problems early), and diligence (knowing what matters). That's how you get from unpredictable projects to predictable ones. You can [explore these principles interactively](/principles/chat) to find what applies to your situation. --- # https://varstatt.com/jurij/p/three-bugs-that-were-actually-my-prompts --- title: Three Bugs That Were Actually My Prompts url: https://varstatt.com/jurij/p/three-bugs-that-were-actually-my-prompts author: Jurij Tokarski date: 2026-03-19 description: Three debugging sessions where I chased AI misbehavior for hours. Each time the model was executing my instructions exactly as written. section: Blog (https://varstatt.com/jurij/archive) tags: debugging (https://varstatt.com/jurij/c/debugging), ai (https://varstatt.com/jurij/c/ai) --- Three debugging sessions. Three different features. Every investigation eventually landed in the same place: my own prompt files. The AI wasn't broken. I was a contradictory author. ## The STRICT Rule That Was Overriding Itself I built a structured interview tool — the kind that walks a founder through their idea one question at a time. The system prompt had this near the top: ``` STRICT: Ask only ONE question per message. Never bundle questions. ``` Users kept getting messages like "How will you make money? What are the major costs to build and run this?" I read the prompt again. Rule was right there. Added emphasis. Still happened. Moved it higher. Still happened. Then I read the interview flow section — the part describing what topics to cover across the session. Step 4 read: ``` **Revenue Streams + Cost Structure** — How will you make money? What are the major costs to build and run this? ``` The model wasn't defying the STRICT rule. It was following the flow description, which listed two topics as a single step and framed them as two inline questions. That structure implicitly granted permission to bundle. The more specific instruction — a concrete flow item with actual question text — overrode the more abstract one. The fix was two things. Unbundle every flow item into separate steps. And add a concrete bad example directly inside the STRICT rule — not just the prohibition: ``` STRICT: Ask only ONE question per message. Never bundle questions. Example of what NOT to do: "How will you make money? What are your costs?" is TWO questions — send one, wait for the answer, then ask the next. ``` Abstract rules lose to specific structural descriptions. The model resolves contradictions by specificity, not by which rule came first or which one you emphasized. If your flow section describes two questions in the same bullet, that description is an instruction — regardless of what you wrote elsewhere. ## The Tool That Read the Prohibition After a discovery session, users could request a full report by email. The tool was registered. The backend handler existed. Users clicked the button. The AI said it couldn't send emails. I checked tool registration — correct. Checked the API call — correct. Checked the backend handler — correct. Everything looked wired up properly at every technical layer. The issue was in a place I hadn't thought to look. I grepped the prompt files for "report": ```bash grep -r "report" mod/discovery/steps/*/prompt.md ``` Every single step prompt had lines like: ``` Do NOT offer to send a report. Do NOT mention sending a report. ``` I'd written those prohibitions months earlier during a different phase of the project. The tool didn't exist yet when I wrote them. By the time it did, I'd forgotten those lines were there. The model wasn't defective. It was obedient to instructions I'd authored and then lost track of. Ten minutes of grepping would have found this immediately. Instead I spent days checking tool registration and API calls. Before investigating code when an AI-powered feature does nothing, grep your prompt files for explicit prohibitions against the behavior you're expecting. Search for "do not" and "don't" across your entire prompt corpus against the relevant action. It takes ten seconds and it would have saved me days on this one. ## The Precondition That Lived Only in Prose After fixing the prohibitions, a new problem surfaced. The model was supposed to ask for the user's email before calling `send_report`. The prompt said: ``` ALWAYS ask for the user's email before calling send_report. Never call send_report without confirmed contact details. ``` In testing, the tool got called with `founder@example.com`. A placeholder the model had generated rather than asking for a real address. The instruction was clear. The model treated it as a suggestion. I made the prompt stronger. Same result — it would comply sometimes, skip the step other times, depending on how the conversation had flowed. Prompt-only enforcement of a precondition is probabilistic. The fix was to move validation into the tool handler itself: ```js const PLACEHOLDER_DOMAINS = ['example.com', 'test.com', 'placeholder.com']; function validateEmail(email) { const domain = email.split('@')[1]?.toLowerCase(); if (!email || !domain) { return { error: 'No email provided. Ask the user for their email address before calling this tool.' }; } if (PLACEHOLDER_DOMAINS.some(d => domain.includes(d))) { return { error: `"${email}" looks like a placeholder. Ask the user for their real email address.` }; } } ``` Two things to notice. First, the validation returns errors instead of throwing them. A thrown exception terminates the tool call with a runtime error the model can't act on. A returned error lands back in the model's context as a tool result — the model reads it, understands what went wrong, and retries: ```js // This crashes. The model gets a runtime error and no useful signal. if (!user_email) throw new Error('user_email is required'); // This works. The model reads the error and asks for the real address. if (!user_email) { return { error: 'user_email is required. Ask the user for their email address, then call this tool again.' }; } ``` Second, `required` in a tool schema is a hint to the model, not a runtime guarantee. Models will omit required fields — sometimes because the value wasn't extracted yet, sometimes for reasons that aren't obvious from the logs. Treat every parameter as potentially absent at the handler boundary. `ALWAYS X` in a prompt is a suggestion. Enforcing X belongs in code. ## The Prompt Is the Program All three bugs came from the same misread of what a system prompt is. I was treating it as documentation — a description of intended behavior that the real system (the code) would enforce. For an LLM-powered feature, that's backwards. The system prompt isn't documentation. It's source code executed by a natural-language interpreter. Contradictions in it don't fail to compile — they resolve according to specificity and proximity rules you never wrote down. Prohibitions execute. Structure is semantics. A flow description with two inline questions is an instruction to ask two questions, regardless of the STRICT rule above it. The debugging instinct to check the API, the tool registration, the network logs — all of that is valid. But it should come after you've read your own prompts as a hostile reader looking for contradictions, prohibitions, and preconditions that only exist in prose. The model is rarely the bug. Read your prompts first. --- # https://varstatt.com/jurij/p/what-a-freelance-web-developer-actually-charges --- title: What a Freelance Web Developer Actually Charges url: https://varstatt.com/jurij/p/what-a-freelance-web-developer-actually-charges author: Jurij Tokarski date: 2026-03-18 description: My actual pricing model, why hourly billing is broken, and what clients should expect when hiring a freelance web developer in 2026. section: Blog (https://varstatt.com/jurij/archive) tags: solo-business (https://varstatt.com/jurij/c/solo-business) --- Every freelance developer I know hates the rates question. Not because it's uncomfortable — because there's no honest short answer. "It depends" is accurate but useless. A specific number without context is misleading. So here's the full picture: what I charge, how I structure it, and why. ## My Pricing I charge $997/week on a rolling retainer. No hourly billing, no project estimates, no surprise invoices. One flat rate, cancel anytime. That's the price. It's on [my website](https://varstatt.com). No negotiation, no custom quotes, no "let me understand your requirements first." The reasoning behind this structure comes from a principle I call [pricing conversation](/principles/partnership/pricing-conversation) — the price should be clear before we ever talk, so the conversation can focus on whether the work makes sense, not whether the budget fits. ## Why Not Hourly I wrote about this in detail in [hours are wrong](/principles/philosophy/hours-wrong), but here's the short version: hourly billing punishes efficiency. If I solve your problem in 2 hours instead of 20, hourly billing means I earn 90% less for being 10x better at my job. That's a system that rewards slow work. Hourly billing also creates an adversarial dynamic. The client watches the clock. The developer justifies their time. Nobody focuses on the actual outcome. Weekly retainers flip this. I'm incentivized to solve problems fast because my rate stays the same. The client is incentivized to give me the most important work because every week costs the same. We both optimize for outcomes. ## What $997/Week Gets You One senior developer, full stack, working on your highest-priority problem. I pick up the top priority, start building, and ship it to review — what "review" means depends on you. For some clients it's a dev environment, for some it's a pull request, for some it's production directly. A task might take a day, or two days, or ten. It depends on complexity. But every working session ends with an [async update](/principles/delivery/async-updates) — what I did, what's next, what's blocked. Here's what a typical week might look like on a feature that takes the full five days — say the client needs a multi-step onboarding flow with email verification. - **Monday:** Review designs, load existing code and design context into Claude, identify patterns to reuse or logical places for new abstractions. Prepare a plan. Update: "Here's the approach, starting implementation tomorrow." - **Tuesday:** Claude generates code following the plan — still requires heavy prompting, manual investigation, and testing to get a working proof of concept. Deploy to dev. Update: "PoC is in dev, core flow works but not polished yet." - **Wednesday:** Cross-browser testing, edge cases, accessibility. Incorporate first batch of client feedback if available. Update: "Feedback addressed, edge cases covered, dev link updated." - **Thursday:** Final testing, final feedback round incorporated. Everything works well on dev. Update: "Feature is solid on dev, shipping to production tomorrow." - **Friday:** Deploy tested feature to production. Health check, smoke tests, verify everything works. Update: "Live in production, all good. Taking next priority Monday." I follow [WIP one](/principles/delivery/wip-one) — one task at a time, finish before starting the next. No juggling three half-done features. No "it's 80% done" for weeks. [Production is done](/principles/delivery/production-is-done). A feature in review isn't done. A pull request sitting for a week isn't done. It ships to production, then it's done. This is one imaginary scenario. The real shape of a week depends on the product, the team, and what's already in place. Some tasks wrap in a day. Some stretch across two weeks. The constant is the rhythm: build, update, ship. ## Freelance Web Developer Rates in 2026 Here's what the market actually looks like right now, pulled from recent industry surveys and platform data. **Global averages:** The global average freelance developer rate is [$101.50/hour according to Index.dev](https://www.index.dev/blog/freelance-developer-rates-by-country). But that average hides massive variation by role — web developers average $45-75/hour, software engineers $60-120/hour, and AI/ML specialists $100-200/hour. In the US specifically, [ZipRecruiter puts the average freelance web developer rate at $45.12/hour](https://www.ziprecruiter.com/Salaries/Freelance-Web-Developer-Salary) ($93,848/year). That's the median — senior developers with specialized skills command significantly more. **By platform:** - Upwork: $10-100/hour, with most web developers landing $15-50/hour - [Arc.dev](https://arc.dev/freelance-developer-rates): Senior developers $80-120/hour - Toptal: $60-150/hour (they screen for top 3%) **By engagement type (retainer):** - Small agencies: $1,000-5,000/month - Mid-size agencies: $5,000-15,000/month - Enterprise/boutique agencies: $15,000+/month ($75-150/hour equivalent) **By geography (same skill level, senior full-stack):** - Eastern Europe: $45-70/hour (35-40% cost advantage over Western markets, per [Index.dev](https://www.index.dev/blog/freelance-developer-rates-by-country)) - Western Europe: $70-110/hour ([Arc.dev](https://arc.dev/freelance-developer-rates) reports UK averages of $75-95/hour, Germany $70-85/hour) - North America: $70-140/hour ([Arc.dev 2026 survey](https://arc.dev/freelance-developer-rates), 5,302 developers) - Australia: $74/hour average ([Arc.dev](https://arc.dev/freelance-developer-rates)) - Switzerland: $90-120/hour (highest in Europe per [Arc.dev](https://arc.dev/freelance-developer-rates)) These numbers are useful for orientation. But they hide the thing that actually matters: what you get for the money. ## Why Hourly Comparisons Are Misleading At $997/week, assuming roughly 15-20 hours of focused work, the effective rate is $50-66/hour — solidly mid-range. But I'm delivering senior-level outcomes. That gap is exactly why hourly comparisons miss the point. A $50/hour developer who takes 40 hours costs $2,000 and might deliver something that needs rebuilding — I've seen this pattern enough to [write about it](/jurij/p/if-you-throw-away-your-mvp-code-it). A senior developer who solves the same problem in 8 hours at $150/hour costs $1,200 and ships production-ready code. The cheaper hourly rate was more expensive. The offshore dev shop at $3,000/month sounds like a deal until you factor in the communication overhead, timezone gaps, and the rebuild three months later. I've inherited enough of these projects to know the real cost. That's why I don't quote hours. I quote weeks. What matters is what ships, not how long it took. ## The Cancel-Anytime Part [Exit freedom](/principles/partnership/exit-freedom) is a core principle. No contracts, no minimum commitment, no cancellation fees. If the work isn't valuable, stop paying for it. This terrifies most freelancers. It motivates me. Every week I need to deliver enough value that paying for next week is obvious. [Weekly accountability](/principles/partnership/weekly-accountability) creates a feedback loop that long contracts don't. If something isn't working, we know within days, not months. ## When My Pricing Doesn't Make Sense I'm not the right fit for everyone. Here's when you should look elsewhere: - **You need 10 hours of work total.** A retainer is overkill. Find someone who bills hourly for small projects. - **You need a team of 5.** I'm a [solo developer](/principles/philosophy/solo-model). I scale with AI and automation, not headcount. - **You want to own the clock.** If tracking hours matters to you, we'll both be frustrated. I optimize for shipped outcomes, not time at the keyboard. - **Your budget is under $500/week.** That's not a judgment — my service just isn't designed for that price point. ## What Clients Actually Care About After working with dozens of clients, the pricing question matters less than people think. What clients actually care about: **Predictability.** "How much will this cost?" has a clear answer: $997/week for as many weeks as the work takes. No scope creep surcharges, no "we discovered complexity" add-ons. **Transparency.** [Full visibility](/principles/partnership/transparency) into what's happening, what's blocked, and what's next. No weekly status meetings — async updates that respect everyone's time. **Ownership.** [Everything I build belongs to the client](/principles/philosophy/client-owns-everything) from day one. Code, accounts, infrastructure. Zero lock-in by design. The rates conversation gets easy when the value is clear. If you're wondering whether $997/week is expensive, the real question is: what's the cost of not shipping this week? If what you actually need is a senior person who picks the stack, makes the architecture calls, and ships your first version — not a freelancer working from your spec — that's a different shape: [what a fractional CTO engagement looks like](/jurij/p/what-a-fractional-cto-engagement-looks-like). --- # https://varstatt.com/jurij/p/nobody-finishes-a-15-minute-ai-interview --- title: Nobody Finishes a 15-Minute AI Interview url: https://varstatt.com/jurij/p/nobody-finishes-a-15-minute-ai-interview author: Jurij Tokarski date: 2026-03-17 description: How I decomposed a monolithic AI discovery interview into 8 standalone tools — each with its own deliverable, landing page, and search intent. section: Blog (https://varstatt.com/jurij/archive) tags: ai (https://varstatt.com/jurij/c/ai), project-stories (https://varstatt.com/jurij/c/project-stories) --- Last year I launched an AI-powered discovery tool for software founders. The idea was simple: instead of paying for a product consultant, sit through a 15-minute AI interview and get a comprehensive development roadmap. Business model, market sizing, personas, competitive analysis, PRD, tech stack, budget, action plan — all in one session, delivered as a PDF report. The output was genuinely useful. Founders who completed it got something they could hand to a developer and start building from. But most founders didn't complete it. ## Where Sessions Died I didn't need sophisticated analytics to see the pattern. Founders would start, get three or four exchanges in, and disappear. Not because the questions were wrong. Because they'd hit a question they couldn't answer yet. "What's your monetization model?" at minute six, right after they'd just gotten excited describing the product idea. Or a market sizing question when they hadn't done that research. The session demanded answers in a fixed order. Real founder thinking doesn't work that way. I spent weeks trying to fix the session — better prompts, shorter flows, smarter branching. None of it changed the completion rate. I was solving the wrong problem: "how do I get founders to finish a 15-minute interview" instead of "what does a founder actually need, when they need it." ## The Insight Came From SEO While researching keywords for content, I noticed something. "Competitive analysis template for startups" — thousands of monthly searches. "TAM SAM SOM calculator" — same. "PRD generator" — same. Each stage of the founder journey had its own search intent, its own moment of urgency. I had been thinking about building a standalone tool around one of these keywords. Then it struck me: my discovery tool already does all of this and more. But a founder searching for "lean canvas generator" doesn't think of it as part of a 15-minute discovery interview. They want the canvas. Right now. The monolithic tool was doing eight things well, packaged in a way that required commitment to all eight. The fix wasn't better prompting. It was decomposition. ## Eight Tools, Eight Deliverables The rebuild started with twelve steps, got trimmed to ten, and settled at eight. One per stage of the founder journey: 1. [Business Model Canvas](/discovery/business-model-canvas) — lean canvas with revenue streams, cost structure, key partners 2. [Competitive Analysis](/discovery/competitive-analysis) — positioning matrix, differentiation signals, competitor tech indicators 3. [Market Sizing](/discovery/market-sizing) — TAM/SAM/SOM with growth assumptions 4. [User Personas](/discovery/user-personas) — typed persona objects with platform preferences and jobs-to-be-done 5. [Feature Prioritization](/discovery/feature-prioritization) — domain classification (core / supporting / generic) 6. [Tech Strategy](/discovery/tech-strategy) — build-vs-buy decisions mapped to domain classification, specific stack recommendations 7. [Project Requirements](/discovery/product-requirements) — scoped feature list, acceptance criteria, out-of-scope boundary 8. [Build Cost & Plan](/discovery/build-cost-plan) — weekly estimate with a concrete action plan attached The last two were originally four separate tools: Build vs Buy, Tech Stack Advisor, MVP Cost Estimator, and Action Plan. I merged them in pairs. "Should I build auth?" and "which auth provider?" aren't sequential questions — they're the same question. A cost estimate without an action plan is just a number that makes founders anxious. Eight made more sense than ten or twelve. Each tool is fully self-contained. It works with no prior context, no prior steps. But designed to hand off cleanly if the founder continues. ## The Architecture Each tool gets its own SEO landing page — keyword-targeted hero, explanation copy, FAQ, and an input form, all server-rendered. The page doubles as the app: before generation it's a landing page Google can crawl, after the founder starts it becomes the chat interface. One URL, two render states. The chat itself is a streaming conversation with a constrained AI model. Each tool has its own system prompt scoped to the decisions that step owns — Feature Prioritization scores by business value only, no effort or cost questions (those belong to later steps). The AI drives the conversation, but the scope is narrow: ask the right questions for this deliverable, produce a typed artifact, stop. Three server-side tools do the heavy lifting: - **update_artifact** — incrementally builds the step's structured output as the conversation progresses - **complete_step** — finalizes the artifact, captures analysis and summary - **send_report** — collects all completed artifacts, generates a consolidated PDF, delivers via email The artifact panel shows the structured output updating in real time as the conversation progresses — the founder sees their canvas or competitive matrix forming, not just chat bubbles. ## How Context Moves Between Tools Each tool produces a typed artifact. The Business Model Canvas produces an object with `key_partners`, `revenue_streams`, `cost_structure`. User Personas produces an array of persona objects. Feature Prioritization produces a classification map. When a founder continues to the next tool, those artifacts get injected into the new tool's system prompt as structured JSON. Chat history doesn't cross tool boundaries — the back-and-forth of step one is noise inside step six. What crosses is the concluded output. Each tool ends with two inline options rendered as suggestion pills on the last AI message: **Continue to [next tool]** or **Send report via email**. If the founder requests the report, all completed artifacts get compiled into a PDF and delivered to their inbox. If they continue, the next tool opens with context already loaded. Both outcomes are first-class. Stopping after step two means you have a competitive analysis report — that's a complete deliverable, not an abandoned session. Email capture happens at the moment a founder requests their report — after they've gotten value, not before they've seen anything. That single change converted capture from a gate into an offer. ## The Prompt Engineering That Wasn't Early in the build, I added a line to each tool's system prompt: "Use prior context if available to inform your analysis." Seemed reasonable. It didn't work. The model would occasionally reference something from an earlier step, but inconsistently and shallowly. Feature Prioritization wasn't connecting domain classifications to the Tech Strategy decisions that depended on them. I spent two hours trying different phrasings before accepting the problem wasn't the wording. The fix was specificity. Not "use prior context" — enumerate every upstream artifact by name, every relevant field, and exactly how it should influence the current step: ```md ## Prior Step Context If the following steps are complete, use their outputs as described: - **Feature Prioritization** — use `domain_classification` (core / supporting / generic) to anchor build-vs-buy decisions. Core = build custom. Generic = always buy. - **User Personas** — use `technical_proficiency` and `platform_preferences` to shape deployment and integration decisions. - **Market Sizing** — use TAM/SAM/SOM scale to calibrate infrastructure complexity. ``` The model follows explicit field references. It ignores vague instructions to "use context." The more precisely you enumerate the step name, the field name, and how to apply it — the more consistently the output reflects what prior steps actually found. ## Build Around the Deliverable The session format is an inherited assumption from chat UIs. It made sense for general-purpose assistants. It doesn't make sense for a process that unfolds across days or weeks, where each stage has its own mental context and its own moment of urgency. Decomposing the monolithic tool changed everything downstream. Eight tools means eight landing pages means eight keywords. Each tool is a complete product for someone who needs just that one thing. The full journey still exists for founders who want it — they just don't have to commit to it upfront. If your AI tool covers something that spans multiple sittings and mental states, the deliverable is the right unit to build around. Not the conversation. The thinking behind this comes from my [Discovery principles](/principles/discovery). > Live: [varstatt.com/discovery](https://varstatt.com/discovery) --- # https://varstatt.com/jurij/p/what-a-fractional-cto-engagement-looks-like --- title: What a Fractional CTO Engagement Looks Like url: https://varstatt.com/jurij/p/what-a-fractional-cto-engagement-looks-like author: Jurij Tokarski date: 2026-03-16 description: Fractional CTO for early-stage startups: same person as the founding engineer who builds your first version. The retainer is the engagement. section: Blog (https://varstatt.com/jurij/archive) tags: retainer-shapes (https://varstatt.com/jurij/c/retainer-shapes) --- "Fractional CTO" is one of those terms that means different things at different stages of a company. The honest version of the engagement, and the one Varstatt offers, sits at one specific point on the arc: pre-team, pre-product or post-pivot, founder-plus-one. ## Fractional CTO = Founding Engineer at the Pre-Team Stage **For an early-stage startup, the fractional CTO and the founding engineer are the same person — the senior developer who builds your first version.** There's no team to lead yet. There's no hiring funnel to run, no engineering org to manage, no board call where someone needs to translate sprint velocity into business risk. There's an idea, a Figma file, and a founder who needs working software. The job at this stage is technical: pick the stack, make the architecture calls, ship the product, watch it in production. A senior person who does that *is* the CTO of the company while there's only one of them — there isn't a separate "leadership" job to fill on top. **Once you reach the stage where CTO-as-management actually matters, you need a full-time CTO, not a fractional one.** The split a lot of services sell — a fractional CTO who advises a few hours a week while a separate engineer or agency does the work — is unstable past pre-product. Real management of a real engineering team isn't a fractional job. The fractional version becomes a hiring consultant or a coach. Both are useful but neither is what someone usually means by CTO. By the time you have engineers who need leading, the company can afford the full-time hire — and the half-time version becomes a bottleneck instead of a leverage point. That's why this engagement sits at one end of the arc and a full-time hire sits at the other, with relatively little in between. ## The Shape The fractional-CTO version of the [Varstatt weekly retainer](/) is the same retainer as every other shape: $997/week, cancel anytime through Stripe, senior engineer working on your product week after week, no minimum commitment. The "fractional CTO" framing just describes the position the engagement fills — the founding-engineer / first-developer / architect-and-builder role, condensed into one person on a weekly retainer. If your bottleneck is "I already have engineers and need senior leadership for them," that's the full-time CTO question — and the right time to make the hire is now, not split the role into a fractional version of someone else's job. If your bottleneck is "I have an idea and I need a senior person to actually build it," keep reading. ## What This Engagement Covers The work the retainer does, week after week: - [Architecture decisions](/discovery/tech-strategy) — stack, infrastructure, build vs. buy — made by someone who has shipped commercial software since 2011 - [Working code, shipped weekly, against priorities you set](/principles/partnership/priorities-not-scope) on a shared task board - An honest read on technical risk: what is breakable, what is irreversible, what can wait - [Direct, async-first communication](/principles/partnership/async-first) — no daily standups, no status meetings - A codebase you fully own from day one ([client owns everything](/principles/philosophy/client-owns-everything)) - Pause and resume freely through Stripe — the engagement bends with the work, not the calendar Out of scope, by design: - Engineer hiring — no resume screening, interviewing, or offer negotiation - Team management — no 1:1s, performance reviews, or managing other developers - Board updates, investor decks, fundraising material - An advisor who only writes documents and never opens the editor ## How This Differs From Other Pre-Team Engagement Shapes A few related shapes occupy the same pre-team slot. Quick read on each so the choice is clear: - **Full-time CTO.** The right answer once you have a team to lead. Pre-team it's expensive idle capacity — the salary load is most of a seed round and the role doesn't have enough of its own work to fill the week. - **Traditional fractional / virtual CTO.** Advisory hours, strategy work, oversight of someone else's implementation. Right fit when you have engineers who need direction and you don't yet need full-time leadership over them. - **Technical co-founder.** Same scope as this engagement, but as equity instead of cash. Right fit when you find one — the supply is small and the matching is hard. - **Development agency.** Optimised for delivery throughput against a defined scope. Right fit when the scope is well-defined and the founder doesn't need a partner on the architecture decisions. - **Freelance developer.** Cost-efficient for implementation when you have the spec. The fractional CTO / founding engineer role is partly about *making* the spec, so it sits one rung above this. The retainer described here is the founding-engineer slot specifically: senior judgment, hands-on implementation, weekly cadence, no agency layer. Plenty of companies start with this engagement and graduate to a full-time CTO once they have a team that needs leading. That's the expected arc, not a failure of the model. ## What the First Six Months Look Like Month one is product setup and the first features. Stack chosen ([the default stack](/principles/delivery/default-stack) unless there's a strong reason otherwise), repo configured, deploy pipeline live, auth and core data model in place, first user-facing flows shipped. Months two through four are sustained build. One major capability per month, broken into weekly tasks. Each week ships something reviewable. The product compounds. You stop sending me Loom videos explaining what you want and start sending me task tickets because the shorthand develops. Months five and six are usually one of three branches. Some founders ramp to a full team and either keep me as the senior IC alongside the new hires, or hand off and graduate. Some hit product-market-fit signal and shift the engagement toward growth features and scale work. [Some pivot or pause — and the retainer pauses cleanly with them](/jurij/p/what-happens-when-a-client-wants-to-stop-or-pause). None of those branches require a contract change. The retainer runs the same way regardless of which branch you're on. ## Who This Is For Non-technical founders who built a deck, did discovery, and now need a working product. Technical founders who can code but want a senior partner sharing the architecture load. Operators who acquired a small SaaS and need someone competent running the engineering side without hiring full-time. Solo founders post-pivot who need to rebuild quickly without re-onboarding an agency. If you are pre-team and pre-traction and you searched "fractional CTO" because you wanted someone to build with you, this is the engagement. ## Other Shapes the Retainer Takes Same retainer, different shapes — pick the one that matches the work in front of you: - **[What a 2-Week PoC Looks Like](/jurij/p/what-a-2-week-poc-looks-like)** — when the first thing to do is validate one technical assumption - **[What a 6-Week MVP Build Looks Like](/jurij/p/what-a-6-week-mvp-build-looks-like)** — when the shape is clear and v1 is the next step - **[What a Code Audit Looks Like](/jurij/p/what-a-code-audit-looks-like)** — when there's already a codebase that needs senior eyes first - **[All retainer shapes →](/jurij/c/retainer-shapes)** ## How to Start The path is the same for every shape: 1. **[Submit a project brief](/brief)** — 2–3 minutes. Within 24 hours, you get an honest read on whether this engagement fits. 2. **15-minute discovery call** — confirm scope and timing, no sales pitch. 3. **Subscribe to the weekly retainer** — work begins the next business day. Cancel anytime through Stripe, no paperwork. If you have questions before any of that, the [project brief form](/brief) has a free-text field — write whatever you need to. --- # https://varstatt.com/jurij/p/how-does-software-get-shipped-without-breaking-things --- title: How does software get shipped without breaking things? url: https://varstatt.com/jurij/p/how-does-software-get-shipped-without-breaking-things author: Jurij Tokarski date: 2026-03-16 description: Continuous deployment, feature flags, monitoring from day one, and safe-to-fail design replace big-bang releases and lengthy QA cycles. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- The traditional approach stacks up weeks of changes, runs a QA cycle, then deploys everything at once. When something breaks, it's buried in a pile of unrelated changes. The book describes a different model: deploy small, deploy often, detect problems fast, and recover in minutes. [Continuous Flow](/principles/delivery/continuous-flow) replaces sprint ceremonies with a priority queue. No velocity tracking, planning poker, or batch releases. The developer works top-down from a ranked list, deploys each piece as it's ready, and moves on. Priorities shift? Reorder the list — no sprint replanning required. [Feature Flags](/principles/delivery/feature-flags) decouple deployment from release. Incomplete work deploys to production behind a disabled flag. The main branch stays production-ready at all times. No long-lived feature branches, no merge conflicts piling up, no big-bang deployment day. When the feature is ready, the flag turns on. If something breaks, the flag turns off — surgical rollback, not full revert. [Quality Gates](/principles/delivery/quality-gates) focus on safe-to-fail, not fail-safe. Preventing every possible bug requires massive overhead and still misses edge cases. The alternative: test proportionally to risk (payment flow gets more scrutiny than a settings page), deploy small changes that are easy to inspect, and invest in the ability to detect and recover quickly. [Monitor Day One](/principles/diligence/monitor-day-one) means observability starts with the first deploy, not after launch. Error tracking, uptime monitoring, analytics, and performance metrics — all running before real users arrive. Launch week has the highest traffic and the most new bugs. That's exactly when visibility matters most. [Production Is Done](/principles/delivery/production-is-done) closes the loop. Work isn't done when the code is reviewed or merged. It's done when it's live in production with monitoring confirming it works. This definition forces simple deployments — if deploying is painful, it gets avoided, and work piles up. --- # https://varstatt.com/jurij/p/what-happens-when-a-client-wants-to-stop-or-pause --- title: What happens when a client wants to stop or pause? url: https://varstatt.com/jurij/p/what-happens-when-a-client-wants-to-stop-or-pause author: Jurij Tokarski date: 2026-03-15 description: Weekly billing, full client ownership, and no lock-in mean stopping or pausing is mechanically simple and penalty-free. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Work doesn't move in a straight line. It surges, then slows. A product might need intense development for six weeks, then nothing for two months, then a focused push again. The book argues the service model should match this reality instead of fighting it. [Exit Freedom](/principles/partnership/exit-freedom) makes stopping mechanically simple. No penalty clauses, no notice periods, no awkward wind-down conversations. Weekly billing means cancellation is a subscription toggle, not a negotiation. This only works because of [Client Owns Everything](/principles/philosophy/client-owns-everything). From day one, the repository sits under the client's account. Cloud services are billed to the client's card. API keys, credentials, documentation — all client-controlled. Nothing is held hostage, so leaving doesn't require extraction. [Pause, Not End](/principles/diligence/pause-not-end) treats the gap as normal, not terminal. Context survives the pause. The task board, repository history, and documented decisions don't disappear. Resuming means picking up where things left off, not starting a new onboarding. [Client Decides](/principles/philosophy/client-decides) puts the call where it belongs. The developer's role is to inform — here's what's done, here's what's left, here's what continuing looks like. The client decides whether the value justifies the cost. It's their business, their money. Without a pause mechanism, teams end up in one of three traps: forced continuity that breeds resentment, hard endings that make restarting expensive, or scope-stretching to justify ongoing billing. Weekly billing with easy cancellation short-circuits all three. --- # https://varstatt.com/jurij/p/how-does-async-collaboration-work-without-meetings --- title: How does async collaboration work without daily meetings? url: https://varstatt.com/jurij/p/how-does-async-collaboration-work-without-meetings author: Jurij Tokarski date: 2026-03-14 description: Async-first communication replaces status meetings with written updates, full transparency, and documentation that builds itself. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Most founders assume working with a developer means daily standups, weekly syncs, and sprint reviews. The book argues the opposite: meetings are a last resort, not a default. The replacement is a combination of three things: written updates after every work session, full visibility into the task board and repository, and documentation that accumulates as a side effect of the process itself. [Async First](/principles/partnership/async-first) sets the default. No one's calendar dictates pace. The developer works when sharpest, the client reviews when convenient. Responses happen in hours, not days — but nobody blocks on waiting for a scheduled slot. [Async Updates](/principles/delivery/async-updates) are concrete and specific. Not "worked on backend" but "Implemented checkout flow, Stripe working in staging, need to handle webhooks next." Decisions get captured in real time. Three months of context — what was built, what was blocked, what changed direction — lives in the thread, not in someone's memory. [Transparency](/principles/partnership/transparency) fills the gaps between updates. Task board, repository, documentation — shared from day one. The client can check progress anytime without asking. No status-anxiety loop where the client asks for updates, the developer stops coding to write them, and less work gets done. [Documentation](/principles/diligence/documentation) happens automatically when the process is transparent. PRs explain the change and the reasoning. Issues capture decisions. Commit history tells the story. No wiki maintenance, no ceremony — just a written trail that exists because the work was done in the open. Meetings still happen — for strategic decisions with multiple paths, complex requirements easier discussed live, or major pivots. The difference is meetings are scheduled when there's a reason, not because the calendar says it's Tuesday. --- # https://varstatt.com/jurij/p/how-should-non-technical-founders-evaluate-developers --- title: How should non-technical founders evaluate developers? url: https://varstatt.com/jurij/p/how-should-non-technical-founders-evaluate-developers author: Jurij Tokarski date: 2026-03-13 description: You can't fully evaluate a developer before working with them. The goal is to minimize risk until you have enough real data. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- You can't fully evaluate a developer before working with them. The only real signal comes from working together. The goal is to minimize risk until you have enough real data to make a confident decision. Ways to minimize risk before committing: - Give a small test project at a budget you're willing to lose if it goes wrong - Start with a trial period before any long-term commitment - Look for services where cancellation is easy and frequent billing creates natural checkpoints [Onboarding](/principles/partnership/onboarding) describes "Week Zero" — a mutual audition before real money changes hands. The client studies deliverables, process, and communication style. The developer studies the project, how the founder works, what decisions they make. Both sides get real data, not pitch decks. [Transparency](/principles/partnership/transparency) means the client sees everything: task board, repository, documentation, work-in-progress. Hidden mess feels professional but builds relationships on performance instead of reality. [Weekly Accountability](/principles/partnership/weekly-accountability) creates short billing cycles with teeth. Delivered value? Continue. Dropped the ball? Stop. No contract to hide behind, no sunk cost. [Exit Freedom](/principles/partnership/exit-freedom) makes cancellation mechanically simple. Code, documentation, and task board belong to the client from day one. Nothing held hostage. Bringing a developer-ready spec ([Project Scope](/discovery/project-scope)) and a reality-checked cost range ([Build Cost](/discovery/build-cost)) to the trial conversation changes the dynamic — quotes that vary 3x usually have scope ambiguity at the root, and a tight PRD makes the comparison apples-to-apples. Don't try to evaluate a developer from a portfolio or interview alone. Set up a low-risk trial. Start small, learn fast, commit when you have real evidence. --- # https://varstatt.com/jurij/p/what-do-most-founders-get-wrong-about-ai-products --- title: What do most founders get wrong about building AI products? url: https://varstatt.com/jurij/p/what-do-most-founders-get-wrong-about-ai-products author: Jurij Tokarski date: 2026-03-12 description: AI is a tool, not a product. What makes a product valuable is domain expertise. Without it, AI products are generic and replaceable. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- AI is a tool, not a product. The misconception — driven by hype — is that adding AI makes something better or more valuable by default. It doesn't. What makes a product valuable is domain expertise. AI amplifies good domain knowledge. Without it, AI products are generic and replaceable. A tender consultant with years of expertise uses AI to transcribe and analyze documents. The value isn't the AI — it's the consultant's workflow and expertise encoded into the software. He was winning tenders before AI existed. AI just lets him scale. A platform for doctors to write clinical notes uses AI to transcribe recordings. But the product only works because of deep domain expertise in how doctors actually work. For 80% of products, AI is a supporting feature, not the core. Customers often don't care if you use AI or cheap freelancers to produce their outputs — they care if the problem gets solved. Don't market AI as your differentiator if the real value is domain expertise. [Worth Building](/principles/discovery/worth-building) applies directly: does the problem deserve a solution, regardless of what technology powers it? [Find the Core](/principles/discovery/find-the-core) asks what capability creates competitive advantage — for most products that's domain knowledge, not the AI layer. [Business Cost](/principles/philosophy/business-cost) is the reminder that craft serves business goals; technology for technology's sake is self-indulgence. Before building an "AI product," answer: What domain expertise do I have? What specific workflow am I making faster or better? If you can't answer these without mentioning AI, you don't have a product concept yet. --- # https://varstatt.com/jurij/p/production-bugs-that-never-threw-an-error --- title: The Production Bugs That Never Threw an Error url: https://varstatt.com/jurij/p/production-bugs-that-never-threw-an-error author: Jurij Tokarski date: 2026-03-11 description: Six bugs across OAuth, Next.js, launchd, n8n, browser APIs, and OpenAI. Every log said success. Every result was wrong. section: Blog (https://varstatt.com/jurij/archive) tags: debugging (https://varstatt.com/jurij/c/debugging) --- Six production failures. Every log said success. The API returned 200. The job exited clean. Each one cost real time — not because the bug was hard to find once I knew where to look, but because the system accepted the input, confirmed receipt, and executed something different from what I intended. No exception. No warning. Just a quietly wrong outcome at the other end. ## The OAuth Token That Baked In the Past A content automation script returned a 403 from the Twitter v2 API. The message: `Your client app is not configured with the appropriate oauth1 app permissions for this endpoint.` I'd already upgraded the app from Read to Read+Write in the Developer Portal. The settings showed the correct value. The error said otherwise. OAuth 1.0a tokens carry the permission scope active at the moment they were generated. Changing the app's permissions afterward does nothing to existing tokens — they permanently hold the scope they were issued with. The Developer Portal shows you a clean green state with no indication that your tokens are now stale relative to your updated settings. The 403 message says "app configuration," which points you at the thing you already fixed. **Lesson:** After any permission change on Twitter, regenerate the Access Token and Access Token Secret immediately. Don't test anything first. **BTW:** Since X moved to pay-per-use pricing in early 2026, the same 403 with the same "oauth1 app permissions" message can also mean your account has no active billing. The Free tier officially supports POST /2/tweets, but developers report it returning 403s intermittently with no configuration changes on their end ([1](https://devcommunity.x.com/t/403-forbidden-on-post-2-tweets-read-and-write-scopes-ignored-on-free-plan/251574), [2](https://devcommunity.x.com/t/unable-to-post-tweet-through-api-403-forbidden-you-are-not-permitted-to-perform-this-action/229413), [3](https://devcommunity.x.com/t/post-on-free-tier/241130)). If you've regenerated tokens and the error persists, check whether your account needs a paid plan or a billing top-up before debugging further. ## The Cache That Survived the Uninstall Removed `@sentry/nextjs` from a project. Pulled it from `package.json`, ran install, cleaned up the config. Next `dev` run threw this: ``` Error: Cannot find module '@sentry/nextjs' Require stack: - .next/server/instrumentation.js ``` The package wasn't in `node_modules`. Wasn't in `package.json`. Nowhere. But Next.js kept looking for it. Inside `.next/server/` was a compiled `instrumentation.js` from a previous build — one Sentry had hooked into during installation. The incremental build never touched that file because I hadn't changed the instrumentation source, only the package. It just sat there, referencing something that no longer existed. ```bash rm -rf .next ``` Then `yarn dev`. No errors. Thirty seconds of actual work after ten minutes of confusion. **Lesson:** Removing a plugin that hooks into Next.js instrumentation means deleting `.next` as part of the removal. Not after the next error — as part of the removal. ## The Job That Reported Success While Running Nothing A scheduled launchd job on macOS. The plist was configured, the wrapper script pointed at the right Node script, everything looked right. launchd reported `completed successfully` on every run. Nothing was being posted. I added logging, ran it manually. The log showed the Node process starting, then silence. Ran it directly from the terminal — it worked fine. With verbose output piped to a log file, the job finally showed something: ``` Error: spawn claude ENOENT ``` The `claude` binary lives at `~/.local/bin/claude`. My terminal knows that because my shell config adds that path. launchd doesn't. It starts processes with a stripped-down environment — no user shell, no `~/.local/bin`, nothing accumulated over years of machine setup. The Node script was swallowing the subprocess error and exiting 0 regardless. launchd saw a clean exit and called it a success. **Fix:** One line in the wrapper script: ```bash export PATH="/Users/jurijtokarski/.local/bin:/opt/homebrew/bin:$PATH" ``` **Lesson:** Use absolute paths in launchd plists. Test jobs with the same stripped environment launchd uses — not from your terminal. ## The Node That Activated Fine, Then Didn't Added an IF node to an [n8n workflow](/jurij/p/what-an-automation-audit-looks-like) to branch between two processing paths. Saved cleanly. Validated cleanly. The editor showed no warnings. Activated the workflow and got: ``` Cannot read properties of undefined (reading 'execute') ``` No node name. No stack trace. Nothing pointing anywhere useful. I checked the Code nodes. Checked the Merge node. Checked the connections. The IF node wasn't even on my radar — it had saved without complaint. Eventually I pulled the raw workflow JSON: ```json { "type": "n8n-nodes-base.if", "typeVersion": 2.3, "parameters": { ... } } ``` The n8n instance didn't have `typeVersion: 2.3` of the IF node. The editor accepted it — it doesn't validate typeVersion against what's installed on the runtime. The execution engine hit an undefined handler and threw. Downgrading to `2.2` fixed it immediately. **Lesson:** The n8n editor and the n8n runtime have different views of what's valid. When an activation error is opaque and traceless, check `typeVersion` before anything else. ## The Stream That Delivered No Audio Building a screen and audio capture feature. Every API call succeeded. Production recordings came back with only the microphone — no shared app audio. No error in the console. `getDisplayMedia` had resolved cleanly, the stream object was there, the video track was present. I spent a while assuming the AudioContext mixing was wrong before checking something obvious: ```js const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); console.log(stream.getAudioTracks().length); // 0 ``` Zero audio tracks. The user had gone through the picker and selected a tab without checking the "Share audio" checkbox. The browser doesn't reject the promise in that case. No warning, no error, no indication the audio side of the request was skipped. The spec gives you an empty array and moves on. **Lesson:** Check `audioTracks.length` immediately after resolution. If it's zero, surface an explicit re-prompt before proceeding. A resolved `getDisplayMedia` call is not a guarantee that you got what you asked for. ## The Sub-Agent Searching the Wrong Store A multi-step analysis pipeline: an orchestrator that reads documents, spawns specialist agents to evaluate content, streams structured results back to the UI. The orchestrator chains turns via `previous_response_id`. Sub-agents were supposed to be isolated, stateless calls. At one step, agent responses were coherent but consistently wrong. Clean outputs, plausible reasoning, wrong knowledge base. What `previous_response_id` carries isn't just conversation history — it inherits the full tool configuration of the parent response, including attached `file_search` vector stores. The orchestrator had a tender documents store bound to it. Every chained orchestrator call accumulated that binding. When the orchestrator's final response ID was passed to a specialist agent — one explicitly configured with a completely different store — the API silently merged the orchestrator's tool configuration in. The agent queried the wrong store. No error. No warning. **Fix:** Agent calls are stateless. They have no legitimate reason to continue a conversation chain. ```typescript // Before const resp = await this.model.responses.create({ model: DEPLOYMENT_NAME, instructions, input, tools, previous_response_id: previousResponseId, stream: false, }); // After — agents never inherit the orchestrator's chain const resp = await this.model.responses.create({ model: DEPLOYMENT_NAME, instructions, input, tools, stream: false, }); ``` **Lesson:** Any sub-agent that needs isolated tools must be a fresh, stateless request with no chain ID. Explicitly configuring different tools does not override what the chain carries in. ## What These Six Have in Common None of them failed at the point of input. The token was accepted. The build succeeded. The job exited. The editor saved the node. The stream resolved. The agent returned a clean response. Every failure happened at the output — in the actual result, not the API boundary. The gap between "I accepted your input" and "the right thing occurred" is where these live. The fix isn't adding more logging to the call sites. It's verifying at the output layer: check `audioTracks.length` after resolution, not before. Pull the raw JSON of a node that failed at activation. Log `err.data` on a 403, not just `err.message`. Check what the agent actually searched, not what you told it to search. Success at the API boundary tells you the system is running. It tells you nothing about what the system is doing. If your codebase is shipping bugs that pass every check on the way out — silent failures, semantic mismatches, "it worked yesterday" regressions — that's the kind of thing a [code audit](/jurij/p/what-a-code-audit-looks-like) is for. --- # https://varstatt.com/jurij/p/using-firestore-transactions-to-handle-race-conditions --- title: Firestore Transactions: Handling Race Conditions Between Cloud Functions url: https://varstatt.com/jurij/p/using-firestore-transactions-to-handle-race-conditions author: Jurij Tokarski date: 2026-03-10 description: A runTransaction example for the case where two Cloud Function instances race to create the same external resource and one needs to win. section: Blog (https://varstatt.com/jurij/archive) tags: firebase (https://varstatt.com/jurij/c/firebase) --- The system creates an OpenAI vector store per company — a dedicated knowledge base the AI secretary queries when answering questions. Creating it is expensive: an API call to Azure OpenAI, followed by writing the returned ID back to the company doc in Firestore. This is a classic race-condition shape: cheap to detect, expensive to double, and the check-then-write pattern doesn't survive concurrency. What I ran into: multiple cloud function instances can process triggers at the same time. If two invocations both check `workflow_2_vector_store_id`, find it empty, and both proceed to create a vector store — you've just orphaned one. It sits there, billed by the token, never used. My first instinct was "just check before creating." That doesn't work — the check and the write are not atomic. Two instances read an empty field at the same millisecond, both proceed. What worked is a Firestore transaction as the gate: ```typescript export const patchCompanyWithVectorStoreIfMissing = async ( companyId: string, vectorStoreId: string, assistantId: string, ): Promise<{ vectorStoreId: string; wePatched: boolean }> => runDBTransaction(async (tx) => { const snapshot = await tx.get(DOC_COMPANY(companyId)); const existing = snapshot.data()?.workflow_2_vector_store_id?.trim(); if (existing) { return { vectorStoreId: existing, wePatched: false }; } tx.update(DOC_COMPANY(companyId), { workflow_2_vector_store_id: vectorStoreId, workflow_2_assistant_id: assistantId, }); return { vectorStoreId, wePatched: true }; }); ``` Both instances still create a vector store optimistically — that's unavoidable, the external API call can't be inside the transaction. But only one wins the write. The loser gets `wePatched: false` and immediately fires cleanup: ```typescript const { vectorStoreId, wePatched } = await patchCompanyWithVectorStoreIfMissing( company.id, result.vectorStoreId, result.assistantId, ); if (!wePatched) { llm.cleanupCompanyVectorStoreAndAssistant(result.vectorStoreId, result.assistantId) .catch(() => {}); } ``` The Firestore transaction is the single source of truth for "has this resource been claimed?" Optimistic creation outside it is fine — as long as the loser always cleans up. The same pattern works for any expensive idempotent creation: Stripe customers, OpenAI assistants, S3 buckets — anywhere the write needs to be atomic but the upstream call can't be. If your Firebase setup has shapes like this hiding in it — concurrency, security rules, billing surprises — that's the kind of thing a [Firebase audit](/jurij/p/what-a-firebase-audit-looks-like) is for. --- # https://varstatt.com/jurij/p/merging-two-firestore-listeners-for-cross-field-or-queries --- title: Merging Two Firestore Listeners for Cross-Field OR Queries url: https://varstatt.com/jurij/p/merging-two-firestore-listeners-for-cross-field-or-queries author: Jurij Tokarski date: 2026-03-10 description: Firestore can't OR across different field types in a real-time query. Two parallel listeners merged client-side can. section: Blog (https://varstatt.com/jurij/archive) tags: firebase (https://varstatt.com/jurij/c/firebase) --- The assignment feature needed a real-time subscription: show tenders where `creator_id == userId` OR `assignee_ids` array-contains `userId`. A cross-field OR. [Firestore's](/jurij/p/what-a-firebase-audit-looks-like) `Filter.or()` works for same-field conditions. For fundamentally different field types — equality vs. array-contains — composite OR queries don't compose cleanly, and the SDK support varies by version. The alternative is a denormalised collection: fan out writes to a `user_tenders` subcollection on every state change. That's a write-time tax and more surface area for issues down the line. The working approach: two separate `onSnapshot` listeners, results merged client-side into a `Map` keyed by document ID. ```javascript export const firebaseCompanyTendersSubscribeByCreatorOrAssignee = ( companyId, userId, callback ) => { const merged = new Map(); const unsubCreator = onSnapshot( query(COLLECTION_TENDERS(companyId), where("creator_id", "==", userId)), (snap) => { snap.docs.forEach((doc) => merged.set(doc.id, { id: doc.id, ...doc.data() })); snap.docChanges().forEach(({ type, doc }) => { if (type === "removed") merged.delete(doc.id); }); callback(Array.from(merged.values())); } ); const unsubAssignee = onSnapshot( query(COLLECTION_TENDERS(companyId), where("assignee_ids", "array-contains", userId)), (snap) => { snap.docs.forEach((doc) => merged.set(doc.id, { id: doc.id, ...doc.data() })); snap.docChanges().forEach(({ type, doc }) => { if (type === "removed") merged.delete(doc.id); }); callback(Array.from(merged.values())); } ); return () => { unsubCreator(); unsubAssignee(); }; }; ``` Deduplication is automatic — same document ID overwrites itself in the Map. Either listener updating triggers a re-merge and emits a fresh array. The cleanup path requires calling both unsubscribes; returning just one leaks the other listener. No fan-out collection. No schema changes. The subscription logic stays in one place. --- # https://varstatt.com/jurij/p/when-should-you-build-custom-vs-buy-off-the-shelf --- title: When should you build custom vs buy off the shelf? url: https://varstatt.com/jurij/p/when-should-you-build-custom-vs-buy-off-the-shelf author: Jurij Tokarski date: 2026-03-09 description: Default to buy. Build custom only when the custom part IS your product's value. Everything else is glue — and glue should come off the shelf. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Default to buy. [Build only when the custom part IS your product's value](/discovery/tech-strategy). Everything else is glue — and you should buy glue, not build it. Founders order developers to build custom authentication systems, spending 20-100 hours and thousands in salary, when Firebase Auth costs a few dollars per month for the same functionality. Same happens with payments, file storage, and notifications. A useful framing from domain-driven design: the core domain is what makes your product unique — build this, spend 80% of effort here. The supporting domain is infrastructure that helps your core work — some custom work is fine. The generic domain is auth, payments, storage, email, notifications — buy these, always. Most founders think "we should build it so we own it." But owning generic software means maintaining it forever. Every security update, every scaling issue, every bug becomes your problem. Buying means someone else owns that problem. [Find the Core](/principles/discovery/find-the-core) identifies the one capability that creates competitive advantage — everything else is bought or deferred. [Scope Shaping](/principles/discovery/scope-shaping) keeps the build focused on what fits inside the appetite. [Consolidation](/principles/philosophy/consolidation) warns that every service added is another failure point. For every feature on your roadmap, ask: "Is this what makes our product special?" If yes, build. If no, buy. --- # https://varstatt.com/jurij/p/how-should-a-startup-choose-its-tech-stack --- title: How should a startup choose its tech stack? url: https://varstatt.com/jurij/p/how-should-a-startup-choose-its-tech-stack author: Jurij Tokarski date: 2026-03-08 description: There is no best stack. There's a best stack for your specific situation. Match the stack to business reality, not trends. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- There is no "best" stack. There's a [best stack for your specific situation](/discovery/tech-strategy). The right question isn't "what's the best technology?" It's "what are our constraints?" Business constraints determine the stack, not personal preference. One client uses Firebase as core. Another uses Google Cloud without Firebase. Another hosted AI models on Azure because they had $30K in Microsoft startup program credits. Credits, existing team, compliance requirements — these are the real inputs. Every developer has a default stack where they have deepest expertise. Deviations from that default are fine, but they cost efficiency and depth. Sometimes [crossing into a new platform like SwiftUI](/jurij/p/swiftui-is-like-react-plus-css-in-js) reveals surprising similarities. The handbook calls this "vector distance from default stack." When evaluating a developer, ask: "What's your default stack, and how far are we from it?" [Context Over Purity](/principles/discovery/context-over-purity) says business constraints trump technical purity — bias toward fewer services and integrations. [Default Stack](/principles/delivery/default-stack) says deep expertise in familiar tools beats starting fresh each time. [Consolidation](/principles/philosophy/consolidation) warns that every service added is another failure point — one good platform beats five specialized ones. Before asking "what's the best stack?" ask: - What team or expertise do we already have? - What cloud credits or existing infrastructure do we have? - What compliance requirements matter? - What does our developer know best? If you have no constraints and no team, pick a developer with a proven default stack and use theirs. --- # https://varstatt.com/jurij/p/how-should-founders-think-about-saas-development-costs --- title: How should founders think about SaaS development costs? url: https://varstatt.com/jurij/p/how-should-founders-think-about-saas-development-costs author: Jurij Tokarski date: 2026-03-07 description: SaaS development isn't a one-time build. It's a weekly investment. The real question is how much per week you're willing to invest. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- SaaS development isn't a one-time build. It's a weekly investment. Most founders think "[build the MVP](/jurij/p/what-a-6-week-mvp-build-looks-like), then launch, then we're done." But successful SaaS products never stop evolving. The real question isn't "how much to build my SaaS?" It's "how much per week am I willing to invest in continuous development?" Agencies sell you a "finished product." But SaaS products aren't finished — they evolve. Founders who understand this from day one make better decisions. They budget for ongoing development, not a big launch event. Launching is the beginning, not the end. [No Split](/principles/diligence/no-split) makes this explicit: development and maintenance aren't separate phases. Features ship, users hit edge cases, the developer fixes them, the client asks for improvements, performance degrades, the developer optimizes. All of this is the same work. [Pause, Not End](/principles/diligence/pause-not-end) acknowledges that work surges, then slows. Weekly billing makes pausing natural — context survives the gap, billing matches activity. The relationship continues; work resumes when needed. [Weekly Accountability](/principles/partnership/weekly-accountability) and [Pricing Conversation](/principles/partnership/pricing-conversation) make the cost explicit and predictable. No renegotiation, no scope creep arguments. If you want to size the weekly cost against your specific feature set before committing, [Build Cost](/discovery/build-cost) shapes scope to budget and surfaces the post-launch operational line — the one founders forget when they price the build. Before starting SaaS development, ask: "Can I afford weekly development costs after launch?" If not, your business model might not be viable yet. SaaS is a subscription business. Your development should be too. --- # https://varstatt.com/jurij/p/whats-the-difference-between-freelancer-agency-and-retainer --- title: What's the difference between freelancer, agency, and retainer? url: https://varstatt.com/jurij/p/whats-the-difference-between-freelancer-agency-and-retainer author: Jurij Tokarski date: 2026-03-06 description: Three service models with different incentive structures. The key insight: productize the workflow, not the service. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- Three models, three different incentive structures. **Fixed-price freelancing** has a perverse irony. Bad projects have unclear scope and clients who don't pay. Good projects require so much upfront scoping — calls, scope documents, negotiation — that the overhead kills profitability. Better projects mean more overhead, which means less profit. **Agencies** typically have one developer doing the work, but the client pays for a team of five: project manager, account manager, QA, and overhead. Client pays triple for the same output. **Retainer** productizes the relationship — the workflow, process, and cadence — not the service itself. Client pays weekly, developer delivers weekly, both stay invested. No awkward renegotiation when a good project wants to continue. The key insight: you can't productize the service (every project is different), but you can productize the relationship. [Hours Are Wrong](/principles/philosophy/hours-wrong) explains why measuring presence instead of outcomes creates broken incentives. [Weekly Accountability](/principles/partnership/weekly-accountability) creates feedback loops with teeth — delivered value means you continue, dropped the ball means you stop. [Exit Freedom](/principles/partnership/exit-freedom) makes cancellation easy so the relationship continues because the work earns it. Sizing the three options against your actual scope is what [Build Cost](/discovery/build-cost) does — same comparison the post above runs, against your specific feature list. Choose a model that aligns incentives. Retainer works when you need continuous development and want predictable costs without scope creep arguments. --- # https://varstatt.com/jurij/p/how-much-does-it-really-cost-to-build-an-mvp --- title: How much does it really cost to build an MVP? url: https://varstatt.com/jurij/p/how-much-does-it-really-cost-to-build-an-mvp author: Jurij Tokarski date: 2026-03-05 description: The $5K-$500K ranges you see online are mostly noise. They reflect service model and overhead, not code quality. section: Blog (https://varstatt.com/jurij/archive) tags: principles-faq (https://varstatt.com/jurij/c/principles-faq) --- The $5K-$500K ranges you see online are mostly noise. They don't reflect code quality — they reflect service model, overhead, and risk tolerance. The code itself is often similar across price points. What changes is the reliability, expertise depth, and service quality around it. Compare three options at roughly the same scope: - **$5K Upwork freelancer** — risk of disappearing, no accountability, no process - **$6K retainer with a solo developer** — consistent, dedicated, weekly billing, real ownership - **$50K agency** — often the same developer doing the work, but you're paying for project managers, account managers, and QA overhead on top If you compared the code from a $5K MVP and a $50K MVP, they'd often be quite similar. The difference isn't the code. It's the overhead around it. There are legitimate reasons to pay more: domain expertise in your specific industry, stability guarantees for enterprise procurement, compliance coverage. But most early-stage founders don't need these things and are paying for them anyway. [Business Cost](/principles/philosophy/business-cost) frames software development as an operational expense — essential, not precious. [Hours Are Wrong](/principles/philosophy/hours-wrong) explains why hourly billing creates perverse incentives for both sides. [Solo Model](/principles/philosophy/solo-model) explains why one developer with modern tools can deliver equivalent output to a small team from five years ago. If you want a defensible number for your scope before you go price-shopping, the [build-cost calculator](/discovery/build-cost) runs the same scope-shaping pass with the 2-3x multiplier surfaced. Don't ask "how much does an [MVP cost](/jurij/p/what-a-6-week-mvp-build-looks-like)?" Ask "what service model fits my risk tolerance and budget?" --- # https://varstatt.com/jurij/p/swiftui-is-like-react-plus-css-in-js --- title: SwiftUI Is Like React + CSS-in-JS url: https://varstatt.com/jurij/p/swiftui-is-like-react-plus-css-in-js author: Jurij Tokarski date: 2026-03-04 description: I had to jump into an iOS codebase with no SwiftUI experience. The fastest way in was mapping everything I already knew to the Swift equivalent. section: Blog (https://varstatt.com/jurij/archive) --- A client brought me into an iOS project mid-stream. The app was already built — screens, navigation, state management, the works — and I needed to understand it fast enough to contribute meaningfully. The codebase was SwiftUI. I had zero SwiftUI experience. My usual move in this situation is to find the mental model that bridges the gap. I've been doing web development long enough that React, CSS, and async JavaScript are second nature. So instead of starting from scratch, I used Claude Code to explore the codebase and kept asking one question: *what's the web equivalent of this?* After a few hours of that, something clicked. SwiftUI is not exotic. If you know React, you already know 80% of the concepts — just with different syntax and a few iOS-specific primitives layered on top. This post is the reference I built during that session. I stripped the client-specific code and kept the patterns that apply universally. ## Core Concept: SwiftUI = React + CSS-in-JS + Declarative UI SwiftUI is declarative and component-based, just like React. Instead of JSX you write Swift, but the mental model is the same: describe what the UI should look like, and the framework figures out how to render it. The three things that felt foreign to me at first — and clicked once I found the right analogy: - **No CSS files.** Styles live directly on the component as chained modifiers. Think CSS-in-JS but without the library — it's just how the language works. - **No DOM.** SwiftUI renders to native UIKit controls under the hood. You never touch them directly, just like you never touch the DOM in React. - **State drives everything.** Change a `@State` variable and the view re-renders. Same contract as `useState`, same mental model, different syntax. ## 1. Components — Structs that return a view, not JSX In React a component is a function that returns JSX. In SwiftUI it's a `struct` that conforms to the `View` protocol and implements a `body` property. The props become `let` constants declared directly on the struct, and instead of a `return` statement you describe the layout inline.
```jsx function NoteCard({ title, subtitle, dateLabel }) { return (

{title}

{subtitle}

{dateLabel}

); } ```
```swift struct NoteCardView: View { let title: String? let subtitle: String let dateLabel: String var body: some View { VStack( alignment: .leading, spacing: 8 ) { if let title = title { Text(title) } Text(subtitle) Text(dateLabel) } .padding(16) .background(Color("NeutralCool0")) } } ```
Key differences: - `struct` instead of `function` - `var body: some View` instead of `return` - Swift types (`String?`, `Int`) instead of JavaScript types - Modifiers (`.padding()`, `.background()`) instead of CSS classes ## 2. Layout — Named containers instead of flex properties On the web layout is a property you set on an element — `display: flex`, `flex-direction: row`. In SwiftUI layout is structural: you pick a container (`VStack`, `HStack`, `ZStack`) and nest views inside it. There are no flex properties to remember because the direction is baked into the container name.
```css .container { display: flex; flex-direction: column; } .header { display: flex; justify-content: space-between; align-items: center; } ```
```swift // like flex-direction: column VStack(spacing: 0) { // like flex-direction: row HStack { Text("My App") Spacer() // like flex-grow: 1 Button { ... } label: { ... } } .padding(.horizontal, 20) } ```
Layout containers: - `VStack` → `display: flex; flex-direction: column` - `HStack` → `display: flex; flex-direction: row` - `ZStack` → `position: relative` + absolute children - `Spacer()` → `flex-grow: 1` - `LazyVGrid` → CSS Grid ## 3. Styling — Modifiers chain where CSS classes would go There are no class names, no stylesheets, no CSS-in-JS library. Every visual property is a modifier method chained directly onto the view. Order matters — `.padding()` before `.background()` gives a different result than after it, just like stacking CSS properties in a specific order can change rendering.
```css .card { padding: 16px; background-color: #fff; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.06); } ```
```swift VStack { ... } .padding(16) .background(Color("NeutralCool0")) .cornerRadius(12) .shadow( color: Color.black.opacity(0.06), radius: 2, x: 0, y: 1 ) ```
Common modifiers: - `.padding(16)` → `padding: 16px` - `.padding(.horizontal, 20)` → `padding-left: 20px; padding-right: 20px` - `.background(Color.red)` → `background-color: red` - `.foregroundColor(.blue)` → `color: blue` - `.font(.system(size: 17))` → `font-size: 17px` - `.fontWeight(.medium)` → `font-weight: 500` - `.cornerRadius(12)` → `border-radius: 12px` - `.frame(width: 100, height: 50)` → `width: 100px; height: 50px` - `.frame(maxWidth: .infinity)` → `width: 100%` - `.shadow(...)` → `box-shadow: ...` - `.overlay(...)` → `::before` or `::after` pseudo-element ## 4. State Management — Property wrappers instead of hooks `@State` is `useState`. The syntax is different but the contract is identical: declare a variable, mutate it, the view re-renders. What takes more time to learn is the broader family of property wrappers — SwiftUI has several, each with a specific purpose, where React hooks tend to blur together.
```jsx function Counter() { const [count, setCount] = useState(0); const [loading, setLoading] = useState(false); return (

{count}

{loading && }
); } ```
```swift struct CounterView: View { @State private var count = 0 @State private var loading = false var body: some View { VStack { Text("\(count)") Button("Increment") { count += 1 } if loading { ProgressView() } } } } ```
State property wrappers: - `@State` → `useState()` — local component state - `@StateObject` → `useState()` with object — owns an ObservableObject - `@ObservedObject` → props (object) — observes external ObservableObject - `@Published` → state in class component — triggers UI updates - `@Binding` → props callback — two-way data binding For shared state, `ObservableObject` fills the same role as React Context or Redux. You mark properties with `@Published` and the views that observe it re-render automatically — no dispatch, no selectors. ```swift class AppViewModel: ObservableObject { @Published var items: [Item] = [] @Published var isLoading: Bool = false @Published var error: String? = nil func loadItems() async { isLoading = true let result = try await repository.fetchItems() self.items = result isLoading = false } } ``` ## 5. Conditional Rendering — Plain if/else, no ternary tricks React leans on ternaries and `&&` because JSX is an expression — it has to return something. SwiftUI uses plain `if/else` blocks because the body is just code, not an expression. The result is actually easier to read once you have three or four states to handle, since you're not nesting ternaries.
```jsx {isLoading ? ( ) : error ? ( ) : ( )} ```
```swift if viewModel.isLoading { ProgressView("Loading...") } else if let error = viewModel.error { ErrorView(message: error) } else if viewModel.items.isEmpty { Text("Nothing here yet") } else { ScrollView { ForEach(viewModel.items) { item in ItemCardView(...) } } } ```
Key differences: - No ternary nesting — `if/else` reads linearly - `if let` unwraps optionals inline — common Swift pattern - Empty state (`items.isEmpty`) fits naturally as another branch ## 6. Lists — ForEach maps items to views `ForEach` is `Array.map()`. The one gotcha: items need to conform to the `Identifiable` protocol, which is SwiftUI's version of React's `key` prop — it needs a stable identity to track which items changed between renders. In practice this usually means your model has an `id` property, which it probably already does.
```jsx {items.map(item => ( ))} ```
```swift ForEach(viewModel.items) { item in ItemCardView( title: item.name, subtitle: item.description, date: formattedDate(item.createdAt) ) } ```
Key differences: - `ForEach` replaces `.map()` — no `key` prop needed if item conforms to `Identifiable` - For grids, swap `VStack` for `LazyVGrid` — CSS Grid with lazy loading built in - `LazyVGrid` takes an array of `GridItem` values to define columns ## 7. Event Handlers — Actions as trailing closures Web events are attributes you attach to elements. In SwiftUI interactive views like `Button` take an action closure as their first argument. For non-interactive views you attach `.onTapGesture`, `.onAppear`, or `.onDisappear` as modifiers — the same way you'd add `onClick` to a `div` in React.
```jsx setValue(e.target.value)} /> ```
```swift Button("Click me") { handleClick() } // $ = two-way binding TextField("Enter text", text: $textValue) ```
Event modifiers: - `Button { action }` → `