<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>LOJIK — Field notes</title>
    <link>https://wearelojik.com/blog</link>
    <description>Field notes on business systems, workflow automation, and reliable software from LOJIK.</description>
    <language>en-us</language>
    <lastBuildDate>Fri, 11 Sep 2026 12:00:00 GMT</lastBuildDate>
    <atom:link href="https://wearelojik.com/rss.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>A contact form that depends on email will lose leads silently</title>
      <link>https://wearelojik.com/blog/contact-form-losing-leads-email-failure</link>
      <guid isPermaLink="true">https://wearelojik.com/blog/contact-form-losing-leads-email-failure</guid>
      <description>If sending the email is the only thing your form does, then an unset API key deletes the submission and shows the visitor an error. Separate capture from notification and the failure costs a notification instead of a lead.</description>
      <content:encoded><![CDATA[<p>If your contact form&#39;s only job is to send an email, every failure in the mail provider is a deleted lead. Record the submission first, then notify — that way losing the provider costs you a notification, not the prospect.</p>
<h2>The short version</h2>
<ul>
<li>A form route that calls the mail API and returns its result has one point of failure and no memory.</li>
<li>When the key is missing or the provider is down, the visitor sees an error and the submission is gone. Nobody is notified, because notification was the thing that failed.</li>
<li>Writing the submission to a table before the send attempt turns a total loss into a delayed reply.</li>
<li>The fix is small. The bug is usually invisible until you go looking, because a form that fails this way leaves no trace of what it lost.</li>
</ul>
<h2>How the failure actually looks</h2>
<p>This site&#39;s contact route had exactly this shape. It validated the payload, called Resend, and mapped the result to a status code. In production the API key was not set, so the route took its unconfigured branch and answered <code>503</code>. When we checked in September 2026, every post to it got that <code>503</code>.</p>
<p>The part worth dwelling on is what the failure left behind. The form did tell the visitor delivery was offline and showed our email address. On our side the failure left nothing behind: no row, no log line naming the visitor, no retry queue. If anyone tried to make contact, all they left was an absence — a form that had never produced a single lead, which reads exactly like a form nobody uses.</p>
<p>That ambiguity is the real cost. &quot;No leads&quot; and &quot;no leads that survived&quot; look identical from the inside.</p>
<h2>Capture and notification are different jobs</h2>
<p>They fail for different reasons, they have different consequences, and they should not share a code path.</p>
<p><strong>Capture</strong> is a write to storage you control. It should happen as early as possible, before any optional work, and it should never be able to take down the request.</p>
<p><strong>Notification</strong> is a call to somebody else&#39;s service. It will fail sometimes. That is a fact about the world, not a bug to be eliminated.</p>
<p>Once they are separate, the question at the end of the request changes. It stops being &quot;did the email send?&quot; and becomes &quot;is the lead safe?&quot; — and the answer to the second question is the one that decides what the visitor should see.</p>
<pre><code class="language-ts">// Capture first, notify second.
const stored = hasLeadStore() ? await recordLead({ ... }) : &quot;NOT_CONFIGURED&quot;;
const captured = stored === null;

if (!hasEmailConfig()) {
  // The lead is safe even though nobody has been told yet.
  if (captured) return NextResponse.json({ ok: true });
  return NextResponse.json({ ok: false, error: &quot;EMAIL_NOT_CONFIGURED&quot; }, { status: 503 });
}
</code></pre>
<p>The route only returns an error when the submission is genuinely lost — when neither the store nor the mail provider took it. A failure the system has already absorbed is not the visitor&#39;s problem to solve.</p>
<h2>Make the capture unable to throw</h2>
<p>A storage helper that throws moves the fragility rather than removing it. <code>recordLead</code> returns <code>null</code> on success or a short error code, and never raises:</p>
<pre><code class="language-ts">export async function recordLead(lead: LeadRecord): Promise&lt;string | null&gt; {
  const config = storeConfig();
  if (!config.url || !config.key) return &quot;NOT_CONFIGURED&quot;;
  try {
    const response = await fetch(url, { method: &quot;POST&quot;, headers, body });
    if (!response.ok) return `STORE_${response.status}`;
    return null;
  } catch (err) {
    return err instanceof Error ? err.message : &quot;UNKNOWN_STORE_ERROR&quot;;
  }
}
</code></pre>
<h2>The trap this creates, and how to close it</h2>
<p>Never throwing is the right call, and it introduces a new way to fail quietly. If the store rejects the write — a wrong key, a wrong project, a missing grant — and the email happens to succeed, the route returns <code>200</code>, the visitor is thanked, and the lead survives only as an email. The durable copy you built the store for does not exist, and nothing tells you.</p>
<p>So the store&#39;s refusals need a detector:</p>
<pre><code class="language-ts">if (stored !== null &amp;&amp; stored !== &quot;NOT_CONFIGURED&quot;) {
  console.error(&quot;[contact] lead store failed&quot;, { stored });
}
</code></pre>
<p>&quot;Not configured&quot; is a deliberate state and stays quiet. &quot;Configured and refused&quot; is a misconfiguration somebody has to see.</p>
<h2>How to tell whether yours is working</h2>
<p>A <code>200</code> from the endpoint is not evidence. While email is working, a misconfigured store returns the same <code>200</code> as a healthy one — that is the whole point of the design, and it is also why the response cannot be the test.</p>
<p>Select the row. Post one submission with a synthetic address, then query the table directly and confirm it arrived, with the fields you expected. If you only check the status code, you are testing the part that was never going to fail.</p>
<h2>What this does not solve</h2>
<p>Durable capture protects the lead. It does not deliver the email, and it does not tell the prospect you will be slower than usual. If the provider is down for a day, you have the submissions and you still have not replied to any of them — which is better than the alternative and is not the same as working.</p>
<p>It also raises the value of the store. Once submissions land in a table instead of an inbox, that table holds contact details you are responsible for: enable row-level security, give it no public policies, and let only the server&#39;s key write to it. Supabase&#39;s secret and <code>service_role</code> keys bypass row-level security, so that key belongs on the server and nowhere else (<a href="https://supabase.com/docs/guides/getting-started/api-keys">Supabase, API keys</a>). A lead store that anyone can read is a worse outcome than the bug you started with.</p>
]]></content:encoded>
      <pubDate>Fri, 11 Sep 2026 12:00:00 GMT</pubDate>
      <author>diego.gonzap@wearelojik.com (Diego González Zapiain)</author>
      <category>Engineering</category>
      <category>Forms</category>
      <category>Reliability</category>
      <category>Supabase</category>
      <category>Next.js</category>
      <category>Lead capture</category>
    </item>
    <item>
      <title>Your rate limiter is probably counting the wrong requests</title>
      <link>https://wearelojik.com/blog/rate-limiter-counting-wrong-requests</link>
      <guid isPermaLink="true">https://wearelojik.com/blog/rate-limiter-counting-wrong-requests</guid>
      <description>If the limiter runs before validation, a visitor&apos;s own typos spend their allowance and their next correct submission is silently discarded. The order of honeypot, validation and limit decides who pays.</description>
      <content:encoded><![CDATA[<p>A rate limiter placed before schema validation counts rejected payloads as if they were submissions. On a form that posts every attempt, a visitor who mistypes five times in a minute, against a limit of five a minute, has their sixth and correct submission refused — and if the refusal is designed to look like success, they are told it worked.</p>
<h2>The short version</h2>
<ul>
<li>Validation failures are not submissions. Counting them makes a user&#39;s own mistakes exhaust their allowance.</li>
<li>Honeypot trips <em>should</em> count, so an address that trips the honeypot is throttled sooner on everything else it sends.</li>
<li>So the order is: honeypot, then validate, then limit. Not limit, then everything else.</li>
<li>If a throttled request is answered like a success — a reasonable design, to stop callers mapping the limit — then this ordering bug is silent by construction. That is what makes it worth checking rather than assuming.</li>
</ul>
<h2>How the bug is born</h2>
<p>You add a limiter to a public POST route. The obvious place is the top of the handler, before the work: refuse early, spend nothing. That instinct is right for expensive work and wrong here, because the first thing after it is validation, and validation rejects things that were never submissions.</p>
<p>We wrote exactly this. The route took a unit, then validated, then processed. An adversarial review caught it before it reached anyone.</p>
<p>The detail that made it real rather than theoretical: the form is rendered with <code>noValidate</code>, so the browser does not block a malformed attempt — every submit reaches the server and every rejection is a server-side <code>400</code>. Five of those in a minute is not a stretch. An empty name, a bad email address, then a message under the minimum length three times. The sixth attempt, the one that was finally correct, would have hit the limit.</p>
<h2>What the visitor would have seen</h2>
<p>This is the part that turns a design mistake into a lost lead. Throttled requests were answered with the same status and body as success, deliberately, so that a caller could not map the limit by watching responses.</p>
<p>Applied to a valid submission that was refused, the same design says: <code>200</code>, &quot;Message received&quot;, no row in the database, no email to anyone. The visitor believes they have made contact. Nobody has any record that they tried.</p>
<h2>It also defeats the obscurity it was built for</h2>
<p>There was a second consequence, and it undoes the reason the responses were made identical in the first place.</p>
<p>Throttled plus invalid returned <code>200</code>. Unthrottled plus invalid returned <code>400 VALIDATION</code>. So anyone could map the limit exactly: post a payload with one field missing, repeatedly, and watch for the flip from <code>400</code> to <code>200</code>. The concealment held for well-formed payloads and evaporated for malformed ones, which are free to generate.</p>
<p>Fixing the ordering closed both problems at once. Once validation runs first, every payload that reaches the limiter is well-formed, so throttled and successful responses really are identical in status, body and headers for everything that gets that far. Not in timing: a throttled request skips the store write and the email call, so it answers faster.</p>
<h2>The order, and the reason for each position</h2>
<pre><code>parse  →  honeypot (counts, answers like success)  →  validate (400, costs nothing)  →  limit  →  process
</code></pre>
<p><strong>Honeypot first, and it costs a unit.</strong> A hidden field filled in means a bot. Charging it means an address that trips the honeypot also spends the allowance its later, clean-looking submissions would use, so a bot that mixes payloads is throttled sooner. It does not stop the honeypot requests themselves; those are answered like success either way. The cost of this choice, and it is a real one: a bot can spend a shared address&#39;s allowance, so a person behind the same NAT pays for it. We took that trade knowingly and wrote a test that states it, rather than discovering it later.</p>
<p><strong>Validation second, and it costs nothing.</strong> A payload that fails the schema was never a submission. Charging for it means a user&#39;s own errors deny their next correct attempt.</p>
<p><strong>The limit third.</strong> Now it only counts real submissions.</p>
<h2>Two smaller things in the same area</h2>
<p><strong>A blank part is a typo, not a zero.</strong> <code>Number(&quot;&quot;)</code> is <code>0</code> in JavaScript (<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number">MDN, Number</a>), so a configuration value of <code>&quot;5,&quot;</code> — a trailing comma — would have parsed as &quot;five a minute, none a day&quot; and closed the route, with every response still looking like success. Review caught it before it shipped. Treat a blank part as invalid and fall back to the default instead.</p>
<p><strong>A deliberate closure should say so.</strong> Concealing a throttle is worth something, because a caller could otherwise map the limit. Concealing a <em>closure</em> protects nothing — everyone is refused — and it leaves the operator who closed the route unable to tell it apart from an open one with their own smoke test. Our closed state answers <code>503</code>, not a success shape.</p>
<h2>What to check in yours</h2>
<p>Three questions, and the first is the one that finds this bug:</p>
<ol>
<li>Does a request that fails validation consume a unit? Post five invalid payloads, then one valid one, and see whether the valid one lands.</li>
<li>Does a throttled response differ from a successful one in status, body or headers — and does that difference change with the payload&#39;s validity?</li>
<li>Where does the address come from? Ours is the first <code>x-forwarded-for</code> element, which is trustworthy on Vercel because Vercel overwrites that header (<a href="https://vercel.com/docs/headers/request-headers#x-forwarded-for">Vercel, request headers</a>), and is client-controlled almost everywhere else (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For">MDN, X-Forwarded-For</a>). That assumption belongs in your documentation, not in your head.</li>
</ol>
]]></content:encoded>
      <pubDate>Fri, 11 Sep 2026 12:00:00 GMT</pubDate>
      <author>diego.gonzap@wearelojik.com (Diego González Zapiain)</author>
      <category>Engineering</category>
      <category>Rate limiting</category>
      <category>Forms</category>
      <category>API design</category>
      <category>Reliability</category>
      <category>Security</category>
    </item>
    <item>
      <title>Two Supabase mistakes that fail silently, and how to catch both</title>
      <link>https://wearelojik.com/blog/supabase-postgrest-silent-failures</link>
      <guid isPermaLink="true">https://wearelojik.com/blog/supabase-postgrest-silent-failures</guid>
      <description>A camelCase column breaks every insert with PGRST204. Supabase&apos;s new API keys are not JWTs and belong in the apikey header. Neither mistake has to surface in your app.</description>
      <content:encoded><![CDATA[<p>Writing to a Supabase table over the REST API has two traps that fail quietly: PostgREST matches JSON keys to column names exactly, so one camelCase field breaks every insert, and the newer <code>sb_secret_…</code> keys are not JWTs, so they belong in the <code>apikey</code> header; anything that verifies the Authorization header as a JWT, such as Edge Function code, fails on one. Both failures happen on Supabase&#39;s side of the request, not in your code, and neither necessarily produces an error in your app.</p>
<h2>The short version</h2>
<ul>
<li>PostgREST maps a JSON key to a column of the <strong>same spelling</strong>. <code>receivedAt</code> needs a column created as <code>&quot;receivedAt&quot;</code>, with the quotes. Unquoted, Postgres folds it to <code>receivedat</code> and every insert fails with <code>PGRST204</code>.</li>
<li>Legacy <code>service_role</code> keys are JWTs and go in both <code>apikey</code> and <code>Authorization: Bearer</code>. The newer <code>sb_secret_…</code> keys are opaque, not JWTs, and belong in <code>apikey</code> alone.</li>
<li>Both failures happen on Supabase&#39;s side of the request, not in your code. If your write helper swallows errors — a reasonable thing for it to do — your application reports success and loses the row.</li>
</ul>
<h2>Trap 1: PostgREST does not convert your casing</h2>
<p>Post a body to <code>/rest/v1/&lt;table&gt;</code> and PostgREST takes each JSON key as a column name verbatim (<a href="https://docs.postgrest.org/en/stable/references/errors.html">PostgREST, error reference</a>). There is no camelCase-to-snake_case translation step. So this payload:</p>
<pre><code class="language-json">{ &quot;email&quot;: &quot;a@example.com&quot;, &quot;receivedAt&quot;: &quot;2026-09-11T22:20:02.865Z&quot; }
</code></pre>
<p>needs a column literally named <code>receivedAt</code>. Postgres folds unquoted identifiers to lower case (<a href="https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS">PostgreSQL, identifiers</a>), so the obvious DDL creates the wrong thing:</p>
<pre><code class="language-sql">-- Creates &quot;receivedat&quot;. Every insert above now fails.
create table public.leads (receivedAt timestamptz);

-- Creates &quot;receivedAt&quot;. Correct.
create table public.leads (&quot;receivedAt&quot; timestamptz);
</code></pre>
<p>The error is specific once you see it:</p>
<pre><code>PGRST204  Could not find the &#39;receivedAt&#39; column of &#39;leads&#39; in the schema cache
</code></pre>
<p>Two ways out, and the choice is worth making deliberately. Quote the identifier and keep the casing your application already sends, which is what we did — it keeps the write path free of a mapping layer, at the cost of a column you must always quote in SQL. Or use snake_case columns and translate in the client, which is tidier in the database and adds a place where a field can be forgotten.</p>
<p>Either is fine. Choosing neither, and discovering the mismatch from production inserts, is the expensive option.</p>
<h2>Trap 2: the new keys are not JWTs</h2>
<p>Supabase&#39;s legacy <code>anon</code> and <code>service_role</code> keys are HS256 JWTs, so either one works in <code>Authorization: Bearer</code>, including the usual shape of sending the same key in both <code>apikey</code> and <code>Authorization: Bearer</code>.</p>
<p>The newer keys do not have that shape. <code>sb_secret_…</code> and <code>sb_publishable_…</code> are opaque strings. Supabase allows one in <code>Authorization: Bearer</code> only when it exactly matches the <code>apikey</code> header, as a backward-compatibility allowance (<a href="https://github.com/orgs/supabase/discussions/29260">Supabase, discussion #29260</a>), and anything that verifies that header as a JWT fails on it (<a href="https://supabase.com/docs/guides/getting-started/api-keys">Supabase, API keys</a>).</p>
<p>A helper that sends the same key in both headers keeps working on the REST API after rotation. What breaks is anything that puts a new-format key in <code>Authorization: Bearer</code> next to a different <code>apikey</code> value, or hands that header to code that expects a JWT. Send new-format keys on <code>apikey</code> alone, as Supabase recommends:</p>
<pre><code class="language-ts">export function authHeaders(key: string): Record&lt;string, string&gt; {
  const opaque = key.startsWith(&quot;sb_&quot;);
  return opaque
    ? { apikey: key }
    : { apikey: key, Authorization: `Bearer ${key}` };
}
</code></pre>
<p>Two things worth saying about that function. It decides <strong>transport</strong>, never privilege — a publishable key takes the same path and runs as the <code>anon</code> role, so your RLS policies decide what it can do. And it keeps the code on the header shape Supabase documents before the legacy keys are retired, which Supabase currently plans for late 2026.</p>
<h2>Why both of these are quiet</h2>
<p>A write helper that never throws is good design. A capture failure should not take down a request that had other work to do. But &quot;never throws&quot; and &quot;never reports&quot; are different, and it is easy to ship the first while accidentally shipping the second:</p>
<pre><code class="language-ts">const stored = await recordLead(lead);   // null on success, a code on failure
if (stored !== null &amp;&amp; stored !== &quot;NOT_CONFIGURED&quot;) {
  console.error(&quot;[contact] lead store failed&quot;, { stored });
}
</code></pre>
<p>Without that check, whenever the rest of the request succeeds, a store refusal returns the same <code>200</code> as a healthy write, and nothing records why the row is missing.</p>
<h2>How to verify, in about a minute</h2>
<p>Do not trust your own success path. Go to the data.</p>
<pre><code class="language-sql">-- Every column, with its real spelling.
select column_name, data_type
from information_schema.columns
where table_schema = &#39;public&#39; and table_name = &#39;leads&#39;;
</code></pre>
<p>Then write one row through the real endpoint and select it back. On our lead store this check confirmed both: the quoted <code>receivedAt</code> column accepted the value, and a publishable key sent with <code>apikey</code> alone reached PostgREST and was refused by RLS — <code>[]</code> on select, <code>42501</code> on insert. That second result is the one worth copying. It proves the key format reaches the database <em>and</em> that the table is closed to anything but the server&#39;s own key.</p>
]]></content:encoded>
      <pubDate>Fri, 11 Sep 2026 12:00:00 GMT</pubDate>
      <author>diego.gonzap@wearelojik.com (Diego González Zapiain)</author>
      <category>Engineering</category>
      <category>Supabase</category>
      <category>PostgREST</category>
      <category>Postgres</category>
      <category>API keys</category>
      <category>Debugging</category>
    </item>
    <item>
      <title>Why small expert teams can now deliver complete business systems</title>
      <link>https://wearelojik.com/blog/one-builder-per-vertical</link>
      <guid isPermaLink="true">https://wearelojik.com/blog/one-builder-per-vertical</guid>
      <description>AI can compress implementation time. The real advantage is giving experienced builders more time for workflow design, testing, and accountable delivery.</description>
      <content:encoded><![CDATA[<p>Custom software used to force a difficult choice. A business could adapt itself to a generic product, or fund a large team and a long implementation. Better development tools have opened a third path: a small expert team can build around the way the business actually works.</p>
<p>That does not make software automatic. It changes where skilled people spend their time.</p>
<h2>Implementation is faster; understanding is still the work</h2>
<p>Modern tools can accelerate routine code, integrations, tests, and documentation. They cannot decide which workflow matters most, why it is failing, what people will trust, or which tradeoff is right for the business.</p>
<p>Those questions require direct observation, judgment, and responsibility. The useful shift is that experienced builders can spend more of the engagement on them instead of paying a large coordination tax.</p>
<h2>The product is the working system</h2>
<p>Code is only one part of a business system. A complete implementation connects:</p>
<ul>
<li>the people responsible for the work;</li>
<li>the information they need;</li>
<li>the decisions and approvals they make;</li>
<li>the software and integrations already in use;</li>
<li>the exceptions that cannot be automated safely; and</li>
<li>the operating plan after launch.</li>
</ul>
<p>A faster coding process is valuable only when all of those pieces still work together.</p>
<h2>What a client should expect</h2>
<p>Smaller delivery teams should increase clarity, not lower the standard. A serious engagement still needs:</p>
<ol>
<li><strong>A clear problem definition.</strong> The team can explain the root cause in business language, not only repeat the requested feature.</li>
<li><strong>A complete workflow.</strong> The implementation covers normal work, exceptions, permissions, and recovery.</li>
<li><strong>Visible acceptance checks.</strong> The client can see what success means and whether the system meets it.</li>
<li><strong>A responsible launch.</strong> Ownership, monitoring, support, and change are defined before the system becomes daily infrastructure.</li>
<li><strong>A human commercial decision.</strong> Scope, price, and commitments are reviewed and approved by people.</li>
</ol>
<p>AI may support the work behind the scenes. It is not the service the client is buying.</p>
<h2>Where the model works best</h2>
<p>The strongest fit is an important workflow that is too specific for off-the-shelf software and too costly to leave manual. The business understands the pain but may not know what the right system should look like.</p>
<p>Examples include exception routing, multi-step approvals, client onboarding, field operations, document-heavy reviews, and work split across several disconnected tools.</p>
<p>The starting point is not a technology selection. It is one recurring problem with a measurable business consequence.</p>
<h2>Fewer handoffs, clearer responsibility</h2>
<p>When the same expert team understands the workflow, designs the system, implements it, and stays through launch, less meaning is lost between disciplines. Questions reach the people who can change the system. Decisions stay connected to the original problem.</p>
<p>That is the durable advantage of a small team: not simply lower cost or faster code, but a shorter path from business reality to a system that works.</p>
]]></content:encoded>
      <pubDate>Sun, 12 Apr 2026 12:00:00 GMT</pubDate>
      <author>diego.gonzap@wearelojik.com (Diego González Zapiain)</author>
      <category>Thesis</category>
      <category>Business systems</category>
      <category>AI</category>
      <category>Delivery</category>
      <category>Software</category>
    </item>
    <item>
      <title>How LOJIK moves quickly without making the client the test team</title>
      <link>https://wearelojik.com/blog/claude-code-solo-workflow</link>
      <guid isPermaLink="true">https://wearelojik.com/blog/claude-code-solo-workflow</guid>
      <description>A practical delivery loop for turning one broken workflow into reliable software, clear ownership, and a supported launch.</description>
      <content:encoded><![CDATA[<p>Speed matters when an important workflow is wasting time every day. But fast delivery is only useful when the finished system is understandable, testable, and ready for real work.</p>
<p>LOJIK uses a focused delivery loop: understand one workflow, build the smallest complete version of the fix, test the normal path and the failure paths, then expand from evidence. The client sees progress in working software—not in a growing pile of project documents.</p>
<h2>Start with the work, not the feature request</h2>
<p>A request such as “add an approval dashboard” sounds specific, but it may describe only the visible symptom. The real problem could be missing information at intake, unclear decision rights, or exceptions that have nowhere to go.</p>
<p>Before implementation, we trace:</p>
<ul>
<li>what should happen;</li>
<li>what happens instead;</li>
<li>who touches the work;</li>
<li>which tools and handoffs are involved;</li>
<li>where time, errors, or uncertainty enter the process; and</li>
<li>what result would prove the workflow improved.</li>
</ul>
<p>That produces a system plan tied to the business problem. It also prevents a polished interface from being built around the wrong process.</p>
<h2>Build a complete slice</h2>
<p>Large projects often look busy for months before anyone can use them. We prefer complete slices: one real input travels through the workflow, reaches the correct decision, records what happened, and ends in a useful result.</p>
<p>A complete slice may be narrow, but it includes the parts that make the workflow trustworthy—permissions, exceptions, notifications, audit history, and recovery. Each slice becomes a working foundation for the next one.</p>
<h2>Test the paths people usually discover too late</h2>
<p>The happy path is rarely where business systems fail. We test the conditions that create emergency spreadsheets and manual workarounds:</p>
<ul>
<li>required information is missing;</li>
<li>an integration is unavailable;</li>
<li>the same request arrives twice;</li>
<li>an approval takes too long;</li>
<li>a person lacks permission;</li>
<li>a file is unsafe or unsupported; and</li>
<li>a change needs to be reversed.</li>
</ul>
<p>The goal is not to claim that failure is impossible. The goal is to make failure visible, contained, and recoverable.</p>
<h2>Make progress easy to evaluate</h2>
<p>Clients should not need to interpret engineering activity to know whether the system is getting better. Reviews center on concrete questions: Does the workflow now handle the agreed scenario? Can the right person see and act on the exception? Is the result recorded? What remains unresolved?</p>
<p>If a result falls short, it returns to implementation with a clear correction. Approval comes from working behavior against agreed expectations.</p>
<h2>Launch includes what happens next</h2>
<p>A system is not finished when it reaches production. It needs an owner, monitoring, support, current documentation, and a safe way to change.</p>
<p>The client can run the finished system with a structured handoff, ask LOJIK to manage it, or transition responsibility over time. That operating choice is designed before launch so the business is not left with software nobody is prepared to own.</p>
<p>Moving quickly is the result of focus and clear feedback. Reliability comes from following the workflow all the way through.</p>
]]></content:encoded>
      <pubDate>Fri, 10 Apr 2026 12:00:00 GMT</pubDate>
      <author>diego.gonzap@wearelojik.com (Diego González Zapiain)</author>
      <category>Craft</category>
      <category>Delivery</category>
      <category>Quality</category>
      <category>Business systems</category>
      <category>Workflow</category>
    </item>
    <item>
      <title>Designing a WhatsApp-first appointment system for Mexican clinics</title>
      <link>https://wearelojik.com/blog/whatsapp-appointments-mexican-clinics</link>
      <guid isPermaLink="true">https://wearelojik.com/blog/whatsapp-appointments-mexican-clinics</guid>
      <description>A clinic does not need another chatbot. It needs a complete appointment workflow that handles language, calendars, exceptions, privacy, and human escalation.</description>
      <content:encoded><![CDATA[<p>For many private clinics in Mexico, the appointment desk continues on WhatsApp after the receptionist goes home. A patient asks for a visit, the message waits until morning, and an open slot goes unused.</p>
<p>It is tempting to call this a chatbot problem. It is really an end-to-end workflow problem.</p>
<h2>The result the clinic needs</h2>
<p>The useful outcome is not an automated reply. It is a confirmed appointment that appears in the clinic’s calendar with the right patient details, instructions, and follow-up.</p>
<p>The system has to understand the request, find a valid opening, collect only the information the clinic needs, confirm the appointment, send a reminder, and give staff a clear way to intervene.</p>
<p>If any one of those steps is missing, the receptionist still has to reconstruct the conversation by hand.</p>
<h2>Design around the channel patients already use</h2>
<p>WhatsApp is familiar, asynchronous, and already part of daily life for many patients and clinics. A successful system should preserve that ease while adding structure behind the conversation.</p>
<p>The patient can write naturally in Spanish. The system identifies the request, asks focused follow-up questions, and shows available times. Confirmed appointments sync to the calendar the clinic already uses, so staff do not have to manage a second schedule.</p>
<h2>Exceptions define whether the system is trustworthy</h2>
<p>Healthcare conversations do not stay on the happy path. The workflow needs explicit routes for situations such as:</p>
<ul>
<li>symptoms that may require urgent human attention;</li>
<li>a patient asking a medical question the system should not answer;</li>
<li>a requested doctor or service that is unavailable;</li>
<li>duplicate or conflicting appointments;</li>
<li>a minor or caregiver booking for someone else;</li>
<li>a change or cancellation near the appointment time; and</li>
<li>a message that cannot be understood confidently.</li>
</ul>
<p>In those cases, the system should pause, explain what happens next, and alert the correct person. Automation handles the repeatable work; clinic staff keep authority over clinical and exceptional decisions.</p>
<h2>Local context is part of the implementation</h2>
<p>The language has to sound natural for the clinic and its patients. Hours, holidays, payment instructions, forms of address, and escalation expectations vary by location and practice.</p>
<p>That context cannot be added as a decorative translation layer. It shapes the questions, the handoff to staff, the reminder schedule, and the information shown before a patient confirms.</p>
<h2>Privacy belongs inside the workflow</h2>
<p>The system should collect the minimum information needed for scheduling, explain how that information is used, limit who can access it, and define retention and deletion behavior. Medical advice and sensitive clinical intake should remain outside the scheduling flow unless the clinic has deliberately designed and approved a compliant process for them.</p>
<h2>Measure the business result</h2>
<p>Useful measures include response time, completed bookings, staff interventions, no-shows, duplicate appointments, and requests that could not be resolved. Those signals show where the workflow is helping and where it still needs human attention or a better rule.</p>
<p>The opportunity is larger than a conversational interface. It is a reliable front desk that follows the appointment from first message to completed visit while keeping the clinic in control.</p>
]]></content:encoded>
      <pubDate>Wed, 08 Apr 2026 12:00:00 GMT</pubDate>
      <author>diego.gonzap@wearelojik.com (Diego González Zapiain)</author>
      <category>Craft</category>
      <category>Healthcare</category>
      <category>Mexico</category>
      <category>WhatsApp</category>
      <category>Workflow design</category>
    </item>
    <item>
      <title>A premium interface still has to explain the product</title>
      <link>https://wearelojik.com/blog/scroll-frame-sequence-webp</link>
      <guid isPermaLink="true">https://wearelojik.com/blog/scroll-frame-sequence-webp</guid>
      <description>Motion and visual polish can earn attention, but conversion depends on clarity, useful hierarchy, fast performance, and an obvious next step.</description>
      <content:encoded><![CDATA[<p>A polished website can feel expensive and still leave a visitor wondering what the company does. Motion earns a few seconds of attention. Clear product communication decides whether those seconds become interest.</p>
<p>The best interface work makes a complex offer easier to understand. It does not ask the visitor to decode the design.</p>
<h2>The first screen has four jobs</h2>
<p>Before a visitor scrolls, the page should answer:</p>
<ol>
<li>What does this company provide?</li>
<li>What problem does it solve?</li>
<li>Who is it for?</li>
<li>What happens when I take the next step?</li>
</ol>
<p>If the hero cannot answer those questions, another animation will not rescue it. The visual system should reinforce the message—for example, showing scattered work becoming one connected workflow—not compete with it.</p>
<h2>Motion needs a specific purpose</h2>
<p>Useful motion can show sequence, causality, progress, or a change in state. It can help a visitor understand how inputs move through a system or how disconnected steps become one flow.</p>
<p>Motion becomes decoration when it repeats the same idea, delays access to content, or exists mainly to demonstrate production value. A good test is simple: if the animation disappears, does the explanation become less clear? If not, the page may be stronger without it.</p>
<h2>Premium does not mean dense</h2>
<p>High-end interfaces often feel calm because each section carries one idea. Strong typography, intentional spacing, a controlled palette, and a small number of distinctive details create more confidence than a screen full of competing effects.</p>
<p>This is especially important for a service that already requires explanation. The visual design should reduce the effort needed to understand the offer.</p>
<h2>Performance is part of the impression</h2>
<p>A cinematic asset that arrives late or stutters on mobile signals the opposite of technical confidence. Every visual should have a performance budget, a responsive composition, and a reduced-motion alternative.</p>
<p>Practical checks include:</p>
<ul>
<li>useful content appears before large media finishes loading;</li>
<li>the main action remains stable while assets load;</li>
<li>mobile layouts do not crop the explanation;</li>
<li>animation avoids blocking scroll or input;</li>
<li>reduced-motion users receive an equally complete experience; and</li>
<li>the page remains clear when viewed on a slower connection.</li>
</ul>
<h2>Every action should say what it does</h2>
<p>Generic buttons such as “Explore,” “Continue,” or “Get started” force the visitor to guess. A stronger action names the value or destination: “Find what to fix first,” “See how the system works,” or “Start my business review.”</p>
<p>The primary and secondary actions should also be meaningfully different. One begins the product experience. The other teaches, shows an example, or explains delivery.</p>
<h2>Conversion is a comprehension test</h2>
<p>The page succeeds when the right visitor can explain the offer in one sentence, recognize their own problem, and predict the next step. That is the standard the visual system serves.</p>
<p>Beautiful design creates desire. Clear product language turns that desire into a confident action.</p>
]]></content:encoded>
      <pubDate>Sun, 05 Apr 2026 12:00:00 GMT</pubDate>
      <author>diego.gonzap@wearelojik.com (Diego González Zapiain)</author>
      <category>Engineering</category>
      <category>Product design</category>
      <category>Performance</category>
      <category>Motion</category>
      <category>Conversion</category>
    </item>
  </channel>
</rss>