# Bartosz Mróz — CX Operations & Automation Consultant I help teams turn business know-how into AI systems that run day-to-day operations, with humans in control when it matters. 7+ years of experience, including work at [Uber](https://www.uber.com/), [Kahoot!](https://kahoot.com/), and [Gelato](https://www.gelato.com/). I've shipped dozens of workflows on [Make](https://www.make.com/) and [n8n](https://n8n.io/) inside an ISO 27001-certified company, where governance and failure handling mattered from day one. I now build AI operating systems and custom MCP connectors on [Cloudflare](https://www.cloudflare.com/) and [Supabase](https://supabase.com/). ## Good fit - You already own a real, recurring operational process — support, fulfillment, cross-system hand-offs — and want it to run more reliably, scale further, or depend less on one person's memory. - You want AI used for what it's good at: language, classification, and summarisation, with deterministic rules, permissions, and consequential actions kept outside the model. - You want the result to be inspectable after launch — someone can see what context a model saw and why it acted, and there's a recovery path when a dependency fails. ## Not a good fit - You want a demo rather than a system with a defined recovery path for a missing field, a timeout, or a contradictory record. - You want a model with broad, unchecked access to sensitive systems, or to take consequential actions with nobody accountable for the outcome. - There's no real process yet — the problem hasn't been named, and nobody owns the exceptions. ## How I engage The shape follows the problem, not the other way round. Sometimes it's a workflow built on Make or n8n. Sometimes it's a secure internal tool or a custom MCP connector. Sometimes the right answer is not to add AI at all — I'll say so. ## Availability I'm currently available for projects and consulting. ## Contact https://www.bartoszmroz.com/#contact --- https://www.bartoszmroz.com/notes/design-to-fail A timeout only tells you one thing: your workflow did not receive an answer. It does **not** tell you whether the refund was issued, the email was sent, or the supplier payment was submitted. That distinction is where good error handling starts. A retry might complete a workflow safely—or perform the same business action twice. This is the framework I use when I design or review a consequential workflow. It helps turn “robust error handling” from a vague requirement into a practical decision about what should happen next: - wait and retry; - stop because something needs fixing; - check what happened before acting again; or - place the work in a clear manual-recovery path. I will use one hypothetical support refund all the way through. You can apply the same questions to a sales-email sequence, a supplier payment, or a workflow reused as a business tool by a person or an AI agent. The more callers reuse a workflow, the more important it is that an unclear result becomes a truthful next state—not a blind repeat. This is deliberately about failure **inside** a workflow: keeping the process truthful and moving after a failed or unclear step. It is not a complete guide to API responses for external callers, dashboards, alerts, monitoring, or incident response. > A retry is a useful mechanism. It is not a decision. ## Meet the moving parts Imagine that a customer asks support for a refund at 10:00. The policy says a renewal can be refunded when the customer asked within 24 hours of the charge. Support needs an answer; the payment provider needs a refund request; and the workflow needs to be able to explain what happened if a call goes quiet. These names sound formal, but they simply stop “the request,” “the refund,” and “the retry” from being confused with one another. | Concept | Type | Description | | --- | --- | --- | | Customer request | Start of the story | The support message that begins the work. In an implementation, its `support_request_id` is often the primary key: one stable ID for this request in the support system. | | Request received at | Fixed point in time | The timestamp saved when the customer asked. It does not move later when a recovery worker runs. | | Refund decision | Recorded answer | The stored answer to “was this request eligible?” It is not the money movement itself. | | Refund attempt | One recorded call | One request to the payment provider. Its `refund_attempt_id` can be the primary key for that attempt, separate from the customer request. | | Same-refund marker | Duplicate guard | A unique value—often called an *idempotency key*—that lets a supporting provider recognise a repeat of the same logical refund. It is not automatically a primary key. | | Provider outcome | External fact | Whether the payment provider accepted the refund. The workflow can ask about it, but it does not control the provider’s system. | | Recovery inbox | Work waiting for a decision | A durable place to hold unclear or failed work with enough context to choose the next permitted action. Engineers may call this a *recovery queue*. | | Check before retrying | Truth-finding step | Looking up the provider outcome before making another potentially duplicate request. The technical name is *reconciliation*. | ## Follow one ordinary failure from start to finish The normal route is simple. The valuable design work starts when the payment provider receives the refund request but the workflow loses the response. ```mermaid flowchart TB accTitle: Refund recovery after the payment provider gives no usable answer accDescr: A support request is recorded with its arrival time, checked against refund policy, and sent to a payment provider. A confirmed refund is recorded. If the response is missing, the workflow records an unknown outcome, checks the provider, then confirms, safely retries, or sends the case for review. Request[Customer asks for a refund] --> Time[Save when the request arrived] Time --> Eligible{Eligible under policy?} Eligible -->|No| Declined[Record the decision] Eligible -->|Yes| Attempt[Record a refund attempt] Attempt --> Provider[Ask the payment provider to refund] Provider -->|Clear confirmation| Confirmed[Mark refund confirmed] Provider -->|Timeout or lost response| Unknown[Mark outcome unknown] Unknown --> Check[Check what the provider did] Check -->|Refund found| Confirmed Check -->|No refund and safe to retry| Provider Check -->|Still unclear| Review[Send for authorised review] ``` The important word is **unknown**. It means “we do not have enough evidence to say successful or failed.” That is much safer than pretending a timeout is a failure when the provider may already have moved money. There is also a small boundary worth knowing. A local database can make two local changes all-or-nothing—engineers call that **atomic**. For example, it can record the refund attempt and mark it `unknown` together. It cannot make your own database and the payment provider’s system one shared all-or-nothing action. That is why the unknown state exists. ## Do not let “now” change the rule This is one of the easiest workflow mistakes to miss. Our customer asked at 10:00, 23 hours and 50 minutes after renewal. The refund attempt then hit a provider outage. At 16:00, a recovery worker picks it up. If the rule is “refund requests received within 24 hours,” the decision must be about **10:00**—when the customer asked—not 16:00, when the workflow happened to recover. | Ask this question | Use this source of time | Why | | --- | --- | --- | | Was the customer eligible when they asked? | The saved `request_received_at` timestamp | The policy is about the customer’s original request. The answer should not drift while the provider is down. | | Has the refund already been issued? | The provider’s current status | This is a safety check about the real world now. It should use current information. | In other words: use “was the renewal within 24 hours when the request arrived?” for an eligibility rule—not “is it within 24 hours right now?” Both kinds of question are useful. The mistake is using a relative question because it is convenient, then discovering that the policy changes every time a workflow is replayed. Capture the business moment the rule is actually about. ## Read HTTP codes as clues, not verdicts Most integrations speak HTTP. A status code is a useful first clue, but it is not the final recovery decision. | Status family | Usually means | The next useful thought | | --- | --- | --- | | `2xx` | The service says it accepted the request. | Read the provider’s response contract. A technical success does not automatically prove every business consequence is complete. | | `4xx` | The request cannot be accepted as sent. | Check the input, permission, or credential. Do not retry forever. A provider change can also create a new `4xx`, so this is not automatically “the developer’s fault.” | | `5xx` | The provider had a server-side problem. | It may be temporary, but a retry is only safe when repeating the action cannot create a duplicate. | A familiar special case is [`429 Too Many Requests`](https://www.rfc-editor.org/rfc/rfc6585/). It belongs to the `4xx` family, but often means “slow down and try later.” When the provider gives a `Retry-After` value, respect it. A [`503` service error](https://www.rfc-editor.org/rfc/rfc9110.html) or a brief connection drop may also be temporary. Neither one proves that a money movement, email, or record creation did **not** happen. In code, this point is often where a builder catches a failed call and chooses the next branch. In a visual builder such as [n8n](https://docs.n8n.io/workflows/executions/all-executions/) or [Make](https://help.make.com/overview-of-error-handling), the same choice is usually expressed with an error route or handler. The surface is different; the recovery question is the same. ## Separate a short delay from a real blocker Before adding a retry, classify the problem in plain language. | If the problem is… | Example | Proportionate next move | | --- | --- | --- | | Temporary | A short provider outage, a rate limit during a backfill, or a brief network interruption. | Wait, then retry a limited number of times. *Exponential backoff* simply means waiting a little longer between each attempt, so the workflow does not keep adding pressure to a struggling service. | | A real blocker | A required field is missing, a credential was revoked, a permission changed, or the provider changed its expected input. | Stop retrying. Record what failed, fix the cause, then decide whether the saved work can be safely resumed. | This is why a blind “retry every error three times” setting is only a starting point. A retry is sensible for a short-lived problem. It is wasteful for a missing permission, and risky when the original call may already have worked. ## When the answer is missing, do not invent one Return to the refund. At 10:03, the payment provider receives the request and issues the refund. The network drops before the workflow receives the response. The workflow sees a timeout. Sending another refund immediately would be unsafe: the first one may already have happened. The safer path is to keep one logical refund even if the network needs several attempts. **Idempotency** is the technical name for that: repeating the same logical request has the effect of one request, not two. It is like writing one claim number on every copy of an insurance form—the insurer can recognise that the copies refer to the same case. [Stripe’s idempotency contract](https://docs.stripe.com/api/idempotent_requests?lang=curl) is a concrete example. It lets a client retry the same create or update request with the same key after a connection error and receive the original result. That does not make every provider idempotent. It means you must read the provider’s contract before treating a retry as safe. ```mermaid flowchart LR accTitle: Safe refund recovery after a timeout accDescr: After a timeout, the workflow records that the refund outcome is unknown, checks the original refund, and either confirms it, safely retries, or sends it for review. Timeout[No response] --> Unknown[Mark unknown] Unknown --> Check{Check refund} Check -->|Found| Confirm[Confirm] Check -->|Safe| Retry[Retry] Check -->|Unclear| Review[Review] ``` If the provider has no duplicate guard, keep a stable business reference where you can search for the original action. This check-before-retrying step is called reconciliation. Sometimes the right answer is still a person with the right authority deciding what to do next. ## Decide what you are actually replaying “Replay the failed run” sounds simple, but it hides four separate decisions. The original refund request came in at 10:00. At 16:00, after a provider outage, you may have a better recovery path than the one that first ran. That does not mean every part of the old run should be repeated or recalculated. | Decide this first | In the refund example | A sensible rule of thumb | | --- | --- | --- | | Which work is still unfinished? | The eligibility decision is already recorded, but the provider outcome is unknown. | Resume only the uncertain payment step after checking it. Do not repeat confirmed work just because the run stopped. | | Which facts belong to the original moment? | The customer asked at 10:00, within the policy window. | Keep the saved `request_received_at` and the recorded eligibility decision. Those facts explain the original case. | | Which facts need a fresh check? | The provider may have issued the refund while the workflow was offline. | Read the provider's current refund status before another money movement. | | Which workflow version should recover it? | The original route did not know how to check this provider outcome; a corrected route does. | Use corrected recovery logic when it adds a safety check without changing the business policy. If the new version would change the policy or authority for this old case, send it for review instead. | A **workflow version** is simply the code or visual flow that was active at a particular time. A later version may fix a bug, recognise a newly observed provider response, or add a safer recovery branch. That can be the right way to finish saved work—but only after checking that the change does not quietly rewrite the decision that the customer originally asked for. ```mermaid flowchart LR accTitle: Choosing how to recover a saved refund case accDescr: A saved refund case preserves the original request facts, checks the provider's current state, then either uses corrected recovery logic when the business policy is unchanged or sends the case for authorised review when the change would alter the old decision. Saved[Saved case] --> Facts[Keep history and check live state] Facts --> Policy{Would new logic rewrite policy?} Policy -->|No| Recover[Use safer logic] Policy -->|Yes or unclear| Review[Authorised review] ``` This is why recovery is not merely a retry setting. It is a decision about the remaining work, the facts that must stay fixed, the facts that must be refreshed, and the logic allowed to finish the case. ## Give unfinished work a safe place to wait Failed or unclear work should not quietly vanish. It needs a durable recovery inbox with the information a person or a later workflow needs to continue. - the customer request and the saved decision inputs; - what the workflow tried to do; - the provider response—or the absence of one; - the current recovery state; and - who is allowed to take the next action. A **dead-letter queue** (DLQ) is a narrower term. For example, [Cloudflare Queues](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/) uses a DLQ for messages that still fail after a configured number of retries. Use “DLQ” when that is genuinely the platform feature you are using. A table called “failed runs” can be a useful recovery inbox without being a DLQ. ## An unfinished process is not usually a deadlock It is tempting to call any partly completed workflow a deadlock. Usually, it is not. Think of a parcel between a sorting belt and the delivery van. You cannot truthfully call it delivered, but the whole warehouse is not frozen. The refund case is similar: support may show a completed tag while the money movement is still unknown. That is an **uncertain business state**. A database deadlock is more specific: two pieces of work each wait for the other to release something, so neither can continue. It is closer to two people each holding the key the other needs. A half-finished refund does not need a database fix; it needs a truthful state and a permitted next move. | What you know | Responsible next move | | --- | --- | | The provider did not receive the request. | Retry under the documented retry rule. | | The provider accepted the refund. | Record the confirmed result. Do not issue another refund. | | The provider outcome is still unknown. | Check again later or hold the case for authorised review. | Sometimes the workflow also needs a **compensating action**: a new, deliberate business action that corrects a confirmed earlier action. Removing an internal “refund completed” tag or releasing an inventory reservation may be sensible. Charging a customer again to “undo” a refund is not technical cleanup—it is a new commercial decision with its own authority and controls. ## Tools can run the retry; you still own the recovery design The distinction is not “visual tools are unreliable and code is reliable.” Both styles can give you useful recovery primitives. | Approach | What it can help with | What still needs a business decision | | --- | --- | --- | | Visual workflow tools such as [n8n](https://docs.n8n.io/workflows/executions/all-executions/), [Make](https://help.make.com/overview-of-error-handling), and [Zapier](https://help.zapier.com/hc/en-us/articles/19220226086797-What-is-replay) | Retry settings, error routes, incomplete-run handling, and replay. | Which steps may resume, which original facts must stay fixed, and which current facts need checking first? | | Code-first durable runtimes such as [Cloudflare Workflows](https://developers.cloudflare.com/workflows/get-started/guide/), [Vercel Workflow SDK](https://vercel.com/blog/a-new-programming-model-for-durable-execution), and [Trigger.dev](https://trigger.dev/docs/idempotency) | Durable steps, retries, persisted progress, and explicit idempotency controls. | Which business states and recovery rules deserve to be named, reviewed, versioned, and tested in your application code? | For a simple, low-risk workflow, a visual tool’s built-in recovery path may be exactly right. I prefer a code-first durable runtime when recovery itself is a substantial part of the business process: the allowed states, duplicate guards, and “what should happen next?” rules can live in ordinary application code alongside the policy they protect. Neither approach makes an external provider change automatically all-or-nothing. Durable steps avoid repeating completed *workflow* work. Duplicate guards and provider checks protect the *business* action at the external boundary. Mature designs often need both. ## When this pattern fits—and when a simpler alternative is better The right level of error handling is a risk decision, not a badge of technical ambition. | Situation | Sensible first design | | --- | --- | | A weekly internal report misses one non-critical field. | Record the failure and repair it manually. Building a complex automatic path may cost more than the occasional fix. | | A customer email might duplicate after a lost response. | Record the recipient and delivery attempt. Check before another send when the provider cannot make the request idempotent. | | A refund or supplier payment might duplicate. | Record the attempt before the call, use the provider’s duplicate guard where available, and keep an authorised manual path for remaining uncertainty. | | Several systems change in sequence. | Name the states explicitly and decide which confirmed actions can be corrected safely. Do not pretend a later failure rolls everything back. | Past execution history and provider documentation help here. They can show which error shapes actually recur and how often, so you do not spend weeks protecting against a one-in-a-year inconvenience—or accept a weekly source of duplicate payments without a plan. > Alignment is sharing responsibility. My job as the builder is to make likelihood, consequence, control cost, and the remaining risk easy to see. The people who own the commercial outcome can then choose the scope with that context. That is much more useful than promising a generic “robust” build. ## Common questions ### Should every `5xx` or timeout be retried automatically? No. First ask what might already have happened. A safe read can often retry. A money movement, outbound message, or record creation needs duplicate protection, a provider check, or a deliberate manual path first. ### Do I rerun the original run, its remaining step, or a corrected workflow? Start with what you know. Keep a confirmed eligibility decision and the request time that justified it. Check the uncertain external action before repeating it. Then use a corrected recovery path only if it fixes the failure without changing the policy for this saved case; otherwise, keep the case visible for an authorised decision. ### Is manual recovery a sign of weak engineering? No. For rare, reversible, low-impact failures, a clear manual path can be the most responsible option. Weak engineering hides the risk or claims certainty that the workflow does not have. ## The takeaway Reliable error handling is not about adding the most retries. It is about giving each failure a truthful next move. - Save the business moment a rule is about; do not let “now” quietly rewrite it. - Retry short-lived problems only when repeating the action is safe. - Recover only the work that remains uncertain, using the right historic facts, current checks, and reviewed workflow logic. - Keep unclear work visible until a system or an authorised person can resolve it. If “robust error handling” appears in your automation brief but nobody has defined what should happen after an uncertain business action, [message me](/#contact). That decision belongs in scope before the workflow is built. --- https://www.bartoszmroz.com/notes/google-sheets-as-database I keep seeing automation briefs that ask for robust systems and explicit error handling, while making a shared [Google Sheets](https://workspace.google.com/products/sheets/) file responsible for the process's operational state. That combination deserves a closer look. My position is not that [Google Sheets](https://workspace.google.com/products/sheets/) is bad, or that every small workflow needs [Supabase](https://supabase.com/). Supabase is an application-backend platform built around a hosted [PostgreSQL](https://www.postgresql.org/) database. In this note, it is a familiar example of a controlled state owner: a place where the team can make rules about identity, relationships, and changes part of the system instead of hoping every user and workflow follows the same grid convention. A spreadsheet can still be a useful place for a person to review input, correct a small batch, or see the current picture. The risk starts when the shared grid becomes the place the business relies on to answer: “Did this happen?”, “What happens next?”, and “Who is allowed to change the answer?” That is a risk decision, not a purity test about tools. The builder should map the realistic failure modes, explain the cost and effort of the available controls, and make a context-aware recommendation. The people who own the commercial, operational, or security consequence decide what remaining risk is acceptable. > Alignment is sharing responsibility. ## Name the moving parts before choosing a tool Imagine a small sales team preparing a customer-reengagement campaign. An operator reviews a list of recipients in a spreadsheet. A workflow sends each email. The sales lead checks the result before planning follow-up. Before picking a tool, I would make the language explicit. That stops “row,” “recipient,” and “sent” from quietly meaning different things to the operator, the automation, and the next person asked to fix an incident. | Concept | Type | Description | |---|---|---| | Spreadsheet | Working surface | The human-editable grid used to review campaign input or see results. It can be helpful without being the final authority. | | Recipient record | Business object | One specific person in one campaign, such as Ada in the August re-engagement campaign. It is not the row where the person happens to appear today. | | Primary key | Database identity | A value that uniquely identifies one record in its own database table, for example `campaign_recipient_id = rec_002`. The database rejects a second record with that same primary key. | | Unique key | Duplicate-prevention rule | A value, or combination of values, that must not repeat. For example, `(campaign_id, recipient_email)` can prevent Ada from being added twice to the same campaign. It does not have to be the record's primary key. | | Foreign key | Relationship rule | A value that must point to a real record somewhere else, such as a recipient's `campaign_id` pointing to an existing campaign. It prevents a recipient from belonging to a campaign that does not exist. | | Source of truth | Authority | The component whose answer the team trusts when it asks whether `rec_002` was sent. Every other view should follow that answer rather than compete with it. | | Workflow worker | Automation | The process that reads a recipient, asks an email provider to send, handles a retry, and records the outcome. | | Side effect | Action outside the record | Something that changes the world beyond the local data: sending an email, creating a ticket, charging a card, or requesting fulfilment. | With those roles clear, the recommendation becomes less ideological. The sheet can remain a useful working surface while a more controlled component owns the answer that matters. ## A row number tells you where something is, not who it is Here is a deliberately small, hypothetical campaign list: | recipient_id | customer_email | status | sent_at | |---|---|---|---| | rec_002 | ada@example.com | pending | | | rec_003 | grace@example.com | pending | | At 09:00, the worker reads row 2. It caches Ada's email address, asks the email provider to send the campaign, and plans to write `sent` back to row 2. While the request is in flight, a sales operator sorts the list by email or inserts a newly approved recipient at the top. Row 2 can now contain Grace. The worker may still send the right email to Ada because it cached her address, then write `sent` against Grace because it remembered only the old row position. The sales lead sees Grace as contacted and may skip her follow-up; Ada still looks pending and may be sent the campaign again later. ```mermaid sequenceDiagram accTitle: A row can move while a workflow is running accDescr: The worker reads Ada from row 2. An operator changes the list while the email request is in flight, and the worker records success against the old row position. participant Worker participant Sheet participant Operator participant Email provider Worker->>Sheet: Read row 2 for Ada Worker->>Email provider: Send to Ada's cached email Operator->>Sheet: Sort list or insert a recipient Worker->>Sheet: Write sent to the old row 2 ``` A separate but related scenario creates duplicates. The email provider may accept Ada's message, but the worker can lose the response because of a timeout or transient network failure. If the workflow's only evidence is “I did not get a clear response,” its next retry may send the same campaign again. The business result is a duplicate email to Ada—not merely an inconvenient technical error. Neither outcome is inevitable. The point is that a read-then-write design needs to preserve identity deliberately; it cannot assume a mutable screen position will remain the same business record. The [Google Sheets API data model](https://developers.google.com/workspace/sheets/api/guides/concepts) does provide stable identifiers for a spreadsheet and each sheet, plus tools such as [named ranges and developer metadata](https://developers.google.com/workspace/sheets/api/guides/metadata). Individual cells do not have unique IDs, though. A1 ranges, header names, and row positions are therefore useful conventions, not an automatically enforced primary key for a business record. ## What “atomic” actually means Atomic means **all-or-nothing for one clearly named unit of work**. If the unit is “store an attempt and mark `rec_002` as sent in one database transaction,” either both database changes succeed or neither does. The record is not left half-updated. It does not mean that every step around that transaction becomes all-or-nothing. This campaign workflow crosses separate systems: ```mermaid flowchart LR accTitle: The campaign workflow has separate steps accDescr: The worker reads a recipient, asks an external email provider to send the message, then records the outcome. These steps are not one shared all-or-nothing operation. ReadRecipient[Read recipient] --> SendEmail[Ask email provider to send] SendEmail --> RecordOutcome[Record sent outcome] ``` | Unit of work | Is it atomic? | What that means in practice | |---|---|---| | One [Google Sheets `batchUpdate` request](https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets/batchUpdate) | Yes, for the updates inside that one request | Google validates the request before applying its updates together. It does not include an earlier read or a later email request. | | One transaction in a [Supabase](https://supabase.com/docs/guides/database/overview) database | Yes, for the database changes inside it | Supabase provides a hosted PostgreSQL database. A [transaction](https://www.postgresql.org/docs/current/tutorial-transactions.html) makes related local changes commit together or roll back together, keeping that database internally consistent. | | Read recipient → send email → record result | No | It crosses the spreadsheet, the worker, and an external provider. A database cannot unsend an email that the provider has already accepted. | That is why “the API has atomic updates” is useful but incomplete. The useful beginner's question is: **which exact steps must either all happen or all not happen, and which steps live outside that boundary?** ## Helpful spreadsheet rules are not the same as enforced business rules A dropdown limiting a `status` cell to `pending` or `sent` is helpful. A protected range can reduce accidental edits. Both are worth using when a spreadsheet is the right working surface. They are not automatically the same as a rule the state owner refuses to break. In software design, that must-always-be-true rule is called an **invariant**. For this campaign, a few useful invariants are straightforward in plain language: - one recipient must have one stable record identity; - Ada must not appear twice in the same campaign by mistake; - every recipient must belong to a real campaign; - a record marked `sent` must have passed the team's defined confirmation point; - a blank email address must not enter the send queue. | If the sheet is the authority | If a controlled state owner is the authority | |---|---| | Two people can add Ada twice without a system-level duplicate check. | A **unique key** on `(campaign_id, recipient_email)` can reject the second record. | | A row can point at a campaign name that was deleted or renamed. | A **foreign key** can require the referenced campaign record to exist. | | `status` can be `sent`, `Sent`, `done`, or a typo, depending on how people and automations interpret it. | The state owner can define which values and transitions it accepts. | | A person can set `sent = TRUE` before the email provider has confirmed the team's chosen success condition. | The worker can record an attempt first and change the status only at that confirmation point. | | A blank address can be discovered after the workflow has already picked up the row. | Required values can be checked before the item becomes eligible to send. | [Google Sheets data validation and protected ranges](https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets/request) are useful user-interface controls. A [Supabase database](https://supabase.com/docs/guides/database/overview) runs on [PostgreSQL](https://www.postgresql.org/docs/current/), whose [constraints](https://www.postgresql.org/docs/current/ddl-constraints.html) provide a different kind of control: the database can reject an invalid primary key, unique key, foreign-key relationship, or required value at the point of write. A database does not make every workflow safe by magic. It does give the team a place to declare and consistently enforce the local rules that matter, instead of asking every operator and automation to interpret the grid in the same way. ## A timeout is not an answer to “was the email sent?” [Google Sheets API guidance](https://developers.google.com/workspace/sheets/api/troubleshoot-api-errors) documents errors such as `500` and `503`, and its [usage limits](https://developers.google.com/workspace/sheets/api/limits) document quotas and retry guidance. That does not mean every integration fails often. It means a production workflow needs a clear answer for what to do when a request fails or its outcome is unclear. Return to Ada. The worker marks `rec_002` as `sending`, sends the request, and the response times out. The technical response is “unknown.” The business question is still open: did Ada receive the campaign or not? A **state machine** is simply the small set of allowed answers to “where is this item in the process?” Here, it stops a timeout being silently treated as either success or failure. ```mermaid flowchart LR accTitle: A campaign message needs an explicit unknown outcome accDescr: A pending recipient begins sending. The workflow can confirm a send, schedule a retryable problem, or mark the outcome unknown when it loses the response. An unknown outcome is checked before another send is attempted. Pending --> Sending Sending --> Sent Sending --> RetryableProblem[Retryable problem] Sending --> OutcomeUnknown[Outcome unknown] OutcomeUnknown --> CheckEvidence[Check provider and attempt record] CheckEvidence --> Sent CheckEvidence --> Pending ``` **Idempotency** is the ability to repeat the same logical request without creating a second effect. In this example, the logical request is “send the August campaign to `rec_002`.” If the email provider supports it, the workflow can give every retry the same idempotency key, such as `august-campaign:rec_002`. The provider can then recognise that both attempts refer to one intended message rather than send two. The workflow still needs a durable attempt record and a way to check an unknown outcome. Waiting longer before a retry—often called exponential backoff—changes timing. It does not tell the worker whether the first request already succeeded. ## When being able to reconstruct the story matters Not every small business process needs a compliance-grade history. For a one-time event reminder, an operator may reasonably check a sent-email list and correct the occasional mistake by hand. The bar changes when the team may need to explain why a customer was contacted, why a fulfilment request was repeated, who approved a change, or how a privacy incident was contained. In that situation, an **audit trail** means a deliberate, queryable history of business decisions and actions—not just a record of which cells looked different later. [Google Sheets version history](https://support.google.com/docs/answer/190843) is useful for restoring a document and investigating visible human changes. It also has documented boundaries: some changes may not appear in cell edit history, and file owners can delete version history. | If the team needs… | A spreadsheet history may be enough when… | A deliberate audit trail becomes useful when… | |---|---|---| | Recovery | An operator can restore a mistaken edit and manually re-run a small batch. | The team must tie a recovery decision to one specific recipient, attempt, and actor. | | Investigation | It is enough to see that a document changed. | The team must answer who triggered an external action, what evidence they used, and what happened next. | | Accountability | A local correction is cheap and low-consequence. | Money, customer trust, sensitive data, contractual commitments, or a repeatable operating process are involved. | This is not a reason to burden a low-risk workflow with enterprise machinery. It is a reason to make the history requirement explicit before a serious incident makes it expensive to reconstruct. ## When this pattern fits: a spreadsheet is a good choice I would keep [Google Sheets](https://workspace.google.com/products/sheets/) in the design when most of these conditions are true: - a person needs to review or edit a small, bounded input list; - one controlled worker processes the list, or there is a clear processing window; - a delay or occasional manual correction is acceptable; - the external action is reversible, low-consequence, or easy to reconcile; and - the sheet is an input or review surface, not the only evidence of what happened. **Hypothetical lower-risk variant:** an operator reviews a one-time CSV export for event reminders. Before sending starts, the worker takes a fixed snapshot: the input stops changing for this run. Each row carries an immutable input ID, meaning an ID that stays attached to the same recipient even if someone later sorts a spreadsheet copy. ```mermaid flowchart LR accTitle: A lower-risk spreadsheet-backed batch accDescr: An operator reviews a fixed CSV snapshot. One worker processes each item in order, appends an attempt result using the stable input ID, and asks for review when an outcome is unknown. ReviewedInput[Reviewed CSV snapshot] --> OneWorker[One worker processes items in order] OneWorker --> AppendResult[Append result using input ID] AppendResult --> ReviewUnknown[Review unknown outcomes] ``` This does not promise exactly-once email delivery. It makes the remaining uncertainty visible: the source input does not move while it is processed, the result is attached to a stable item ID instead of a row coordinate, and an unclear outcome is reviewed rather than hidden behind `sent = TRUE`. ## The recommendation should be a risk decision, not a tool verdict There is no honest row-count threshold at which a spreadsheet suddenly becomes a database. Consequence and control matter more than volume. | Question to work through together | Example of a lower-risk answer | Example of a higher-risk answer | |---|---|---| | What is the process trying to achieve? | Send a reviewed, one-time reminder batch. | Keep an ongoing order, inventory, or customer lifecycle accurate. | | What happens if one record is wrong? | A delay or manual correction is acceptable. | Money, privacy, fulfilment, customer trust, or a contractual obligation may be affected. | | Who can change the state? | One controlled worker after a short review window. | Several operators, forms, automations, or outside systems. | | Which rules must always hold? | A visible list and human check are enough. | Duplicate prevention, real relationships, required values, and controlled transitions must be enforced. | | What can a retry do? | Re-running is harmless or simple to check. | It may duplicate a charge, message, fulfilment, or ticket. | | What does the team need to prove later? | A snapshot and a manual correction path are enough. | Each decision, actor, attempt, and outcome needs a reliable history. | | What can the team afford now? | A simple process plus a documented manual check is proportionate. | The cost of an incident outweighs the extra work of a controlled state owner. | The builder's responsibility is to turn those answers into an honest recommendation: name the plausible consequence, show the control that reduces it, explain what that control costs, and make the remaining risk visible. The executive or process owner then decides whether that residual risk is acceptable. That alignment is not a hand-off of blame. It is the point at which the people building and operating the system share responsibility for a conscious decision. ## When the spreadsheet should stop being the authority I would move authoritative state to an existing business system or a database when the process depends on several of these properties: - multiple people or workers can change the same answer at once; - a record needs a durable identity that is independent of its visible row; - unique, primary-key, foreign-key, or state-transition rules need consistent enforcement; - a retry can create a duplicate charge, message, fulfilment, or ticket; - a wrong value can affect money, privacy, customer trust, or compliance; or - the team needs a dependable way to recover from partial success across systems. The destination does not always have to be a new [Supabase](https://supabase.com/) project. If a controlled store is appropriate and no existing system owns the concept, Supabase is one familiar option—not a default answer. If an order system already owns orders, let it own order state. If a CRM owns a customer lifecycle, do not create a second ledger in [Google Sheets](https://workspace.google.com/products/sheets/). The principle is to give authority to the component that already owns the business concept, or create a deliberately owned store when none exists. The spreadsheet can still be valuable as an operator-friendly input, review screen, or export. The risky design is letting that helpful view become a second write path that other systems silently trust. ## Common questions ### Can a spreadsheet be the right source of truth? Yes—when the process is small, controlled, reversible, and the people who own it understand the cost of a mistake. The important decision is not whether a [Google Sheets](https://workspace.google.com/products/sheets/) file exists; it is whether the process needs stronger identity, duplicate prevention, control over simultaneous changes, or recovery than the team has deliberately built around it. ### Does moving to a database make a workflow exactly-once? No. A transaction in a [Supabase](https://supabase.com/docs/guides/database/overview) database can make its own database changes all-or-nothing. It cannot automatically undo an email, payment, or ticket that an external provider has already accepted. The workflow still needs clear states, idempotency where supported, and a way to reconcile an unknown result. ### Can the spreadsheet remain the operator interface? Often, yes. Let people use the sheet to review input or see a current result, but route the authoritative change through the system that owns the business state. If operators need to make that change, design that action deliberately instead of treating a shared [Google Sheets](https://workspace.google.com/products/sheets/) file as an accidental API. ## The takeaway The useful question is not “Can this be built with [Google Sheets](https://workspace.google.com/products/sheets/)?” It is “What is the cost of the wrong answer, what control is proportionate, and who has agreed to the remaining risk?” - Keep the spreadsheet when its convenience matches the consequence of being wrong. - Move authority when the process needs guarantees the grid does not own by default. - Make the trade-off visible, agree it with the people who own the outcome, and document what happens when reality does not follow the happy path. If a spreadsheet has quietly become the authority for an operational process you need to make safer, [message me](/#contact) and tell me where the current hand-off breaks. --- https://www.bartoszmroz.com/notes/how-to-build-a-shared-ai-skill-library-with-mcp A shared AI skill library should solve distribution, not become another place where a team writes prompts. The bounded v1 is a reviewed library of playbooks, a small registry, and a read-only [MCP](https://modelcontextprotocol.io/) path that lets an AI client discover and retrieve only approved guidance. This note teaches the pattern, not a universal product recipe. The reader should be able to sketch a bounded v1, explain why the boundaries exist, and recognize when a shared library is too much architecture for the problem. ## The domain model comes before the connector The useful nouns are not “prompt” and “tool.” They are the people, authority, artifacts, and boundaries that determine whether a playbook can be trusted. | Concept | Type | Description | |---|---|---| | Contributor | Actor | Proposes a playbook after learning something useful. A contributor may draft, but cannot silently publish. | | Process owner | Actor | Owns how a business area such as returns, delivery, or billing should work and is accountable for the guidance. | | Reviewer | Actor | Checks the proposed playbook for accuracy, safety, scope, and escalation boundaries. In a small team, this can be the process owner. | | Support representative | Actor | Uses an AI client while helping a customer and may read only approved guidance allowed for that role. | | Skill body | Entity | The full playbook: purpose, steps, examples, limits, and escalation path. The canonical content lives in the library. | | Skill registry | Entity | The catalogue card for a playbook: name, purpose, owner, version, approval status, and access policy. | | AI client | System | The application where a person asks for help, such as [Claude](https://claude.com/), [ChatGPT](https://openai.com/), [Codex](https://openai.com/codex/), or [Cursor](https://cursor.com/). | | [MCP](https://modelcontextprotocol.io/) connector | System | The bounded read path that authenticates the client, lists allowed skills, and retrieves one approved skill body. It is a librarian, not the library. | | Approved version | State | A skill body and registry record that passed review and may be returned to an eligible client. Draft and rejected versions are not discoverable. | The relations matter as much as the nouns: a contributor proposes a skill, a reviewer approves a version owned by a process owner, and an authenticated AI client retrieves that approved version for a representative. The connector may read the library; it does not decide who is allowed to approve the process. ## The bounded v1 has four pieces Start with four building blocks: 1. **Skill storage** holds the full playbook in one canonical location. 2. **A skill registry** holds the short catalogue record and approval state. 3. **An [MCP](https://modelcontextprotocol.io/) connector** authenticates the caller and serves a tiny read surface. 4. **A review signal** tells the accountable reviewer that a new version needs attention; the approval record still belongs in the registry. ```mermaid flowchart LR accTitle: The bounded read path for a shared AI skill library accDescr: A support representative asks an AI client for guidance. The client calls an authenticated connector. The connector reads an approved catalogue record and the matching skill body before returning it. Representative[Support representative] --> Client[AI client] Client --> Connector[Governed connector] Connector --> Registry[Skill registry] Connector --> Storage[Skill body storage] Connector --> Client ``` The important separation is between the long skill body and the short registry record. The client sees a small catalogue before it loads detailed guidance. That keeps the context surface bounded as the library grows. ## The read path should stay smaller than the library The [MCP](https://modelcontextprotocol.io/) connector needs only two operations for a useful v1: ```text list_skills() -> name, purpose, owner, version, and approval state for allowed skills get_skill(skill_name) -> the full approved playbook for one skill the caller may read ``` Do not turn every playbook into a separate [MCP](https://modelcontextprotocol.io/) tool. If the team has 60 skills, the client would see 60 tool names before it understands the support question. A catalogue operation plus one retrieval operation keeps discovery proportional to the task. The connector is also the policy boundary. It authenticates the client, checks the caller’s read permission, hides draft and rejected versions, and returns only an approved body. The [MCP](https://modelcontextprotocol.io/) protocol can carry the request; it does not choose the organisation’s role model for you. ## Publishing comes before browsing The reading path is easy to imagine: a representative asks an AI client for help, the client finds a relevant skill, and the connector retrieves it. The publishing path is what makes the result a shared capability rather than a folder of competing prompts. ```mermaid flowchart TD accTitle: The publishing lifecycle for a shared AI skill accDescr: A contributor drafts a playbook and submits it for review. A reviewer checks the process, scope, examples, and escalation boundary. Only an approved version enters the registry and storage for governed retrieval. Contributor[Contributor] --> Draft[Draft skill] Draft --> Proposal[Submitted proposal] Proposal --> Review[Reviewer checks] Review --> Approved[Approved version] Approved --> Registry[Skill registry] Approved --> Storage[Skill body storage] ``` The smallest useful publishing sequence is: - a contributor drafts one recurring playbook; - the proposal names its process owner and reviewer; - review checks accuracy, examples, boundaries, and escalation; - approval publishes a version to the canonical library; - the next eligible request can discover that version through the connector. Drafts are proposals. Only approved versions are discoverable. ## A delayed-order skill makes the boundary concrete Do not begin with a library of 100 generic prompts. Choose one recurring support situation where correct process matters. The following registry record is illustrative, not a production record; its purpose is to show the minimum fields that make publication inspectable. ```text name: delayed-order-reply purpose: Explain a late order without making unsupported delivery promises. owner: Head of Customer Service status: approved version: 3 ``` The full skill body belongs in storage. The registry record is the catalogue card. An [MCP](https://modelcontextprotocol.io/) `list_skills` response can return the short record; `get_skill("delayed-order-reply")` returns the full playbook only when the assistant is working on that kind of customer question. A real body would define: - when the skill applies; - what checks the assistant must make; - the order in which it explains a delay; - promises it must not make; - the point where a human owns the case; and - one good example of the finished reply. That is enough structure to make the library searchable, reviewable, and safe to change. ## The governance boundary is the real architecture The four controls that make this v1 safe enough to test are: | Control | What it protects | What the connector or library must do | |---|---|---| | Authentication | The identity of the caller | Know who is connecting before returning a skill | | Roles | The difference between proposing, approving, and reading | Keep contributor, reviewer, and representative permissions separate | | Approved-only discovery | The trustworthiness of returned guidance | Hide draft and rejected versions from ordinary readers | | Secret separation | Customer data and credentials | Keep passwords, credentials, and customer records out of skill bodies | The connector should be read-only in v1. A playbook may explain a process, but it should not become an unreviewed action surface just because the client can call it. ## Alternatives I would reject for the first version The simplest design is not always the smallest-looking design. These are the alternatives I would deliberately reject for this v1: | Alternative | Why it is tempting | Why I would reject it now | When it becomes reasonable | |---|---|---|---| | Every skill is its own [MCP](https://modelcontextprotocol.io/) tool | The tool list looks explicit | Discovery becomes noisy and the client sees the whole catalogue too early | A very small, stable capability set | | One shared document with no registry | It is quick to start | No clear owner, version, approval state, or role boundary | A private experiment before multiple people contribute | | Write access from the AI client | It feels like end-to-end automation | It mixes guidance retrieval with consequential actions and expands the failure surface | A separately designed action workflow with approvals and audit | | A custom authoring interface first | It promises a polished workflow | It delays learning whether people contribute and review useful guidance | A real review queue or ownership problem appears | The accepted trade-off is deliberate simplicity: a small read path and manual review create less automation, but they make the operating model legible before the system earns more complexity. ## When the pattern fits and when it does not ### When this pattern fits Use it when a team has repeated process knowledge scattered across personal prompts, chat history, and documents, and needs a reviewed way to retrieve the right guidance in the flow of work. It fits especially well when playbooks need owners, versions, and escalation boundaries but the client should not perform the business action itself. - Good fit: repeated guidance, named ownership, and a separate action boundary. - Good fit: a team needs approved versions without forcing every representative to maintain a local copy. ### When it does not fit Do not build this just to centralize a few static instructions that one person can maintain in an ordinary document. Do not use it as the action layer for refunds, cancellations, money movement, or irreversible changes. Those need approval, idempotency, limits, audit trails, and recovery from partial writes. - Non-fit: one person maintains a small, stable document. - Non-fit: the proposed connector would perform irreversible business actions. A simpler shared document or a normal service endpoint may be the better answer. The point of the pattern is governed retrieval, not adding [MCP](https://modelcontextprotocol.io/) to every knowledge problem. The related [order-context case study](/notes/automating-order-status-for-print-on-demand) shows the same identity, ownership, and disclosure reasoning applied to a live operational read path. ## Common questions ### Does every representative need the same AI client? No. The library can stay independent of the client, provided each client supports the chosen [MCP](https://modelcontextprotocol.io/) connection and sign-in method. Start with one client and validate additional clients deliberately. ### Why not store prompts in a shared document? You can start there. The registry and connector become useful when you need to know which version is approved, who owns it, who may see it, and how a client retrieves only relevant guidance. ### Should every skill be an [MCP](https://modelcontextprotocol.io/) tool? Usually no. Keep the [MCP](https://modelcontextprotocol.io/) surface small: a catalogue operation plus a retrieval operation is enough for a bounded v1. ### When do we need a dedicated authoring interface? When the review queue, ownership, and version history are difficult to manage through the workflow the team already has. Build it in response to that friction, not before it exists. ## The takeaway - Keep the skill body, registry, connector, and approval boundary distinct. - Expose a small read path: list approved guidance, then retrieve one approved playbook. - Start with one real process and add complexity only when ownership, review, or scale proves that the simpler design is no longer enough. If your team has useful process knowledge trapped in personal prompts or chat history, [message me](/#contact) and tell me where the shared capability currently breaks. --- https://www.bartoszmroz.com/notes/automating-order-status-for-print-on-demand I built a read-only order-context service for a print-on-demand company. The primary success criterion was simple: reduce human handoffs for order-status questions without weakening ownership or disclosure controls. The measured result I can defend is a 12 percentage-point increase in overall support deflection after release. The order-status conversations improved too, but the reporting did not isolate the service as the only cause of the overall change. That attribution limit matters. This is evidence of a useful intervention, not a claim that one workflow created every point of improvement. The service checked merchant ownership, joined four operational systems, filtered the result before the model saw it, and returned one explanation through [Fin](https://fin.ai/) in [Intercom Messenger](https://www.intercom.com/messenger). It did not give the model broad access or let a read-only question become an accidental write. ## The result was a 12-point change, not a promise of savings The primary KPI was support deflection: a conversation resolved without being handed to a human agent. The business outcome was additional support capacity. It was not automatically realized cost savings. | Measure | Baseline or basis | Observed result | Business translation | Evidence or limit | |---|---|---|---|---| | Primary KPI: support deflection | Before and after release | +12 percentage points overall | More conversations resolved without a human handoff | The service contributed to the change, but the reporting did not isolate it as the only cause | | Derived capacity | 10,000 conversations × 12 points | About 1,200 additional deflections | Roughly €6,000 of outsourced handling capacity at an estimated €5 per conversation | An estimate, not realized savings; it depends on staffing or BPO costs flexing with volume | | Secondary operational benefit | Human agents previously assembled context across systems | One read-only explanation also became available to internal agents through [Slack](https://slack.com/) | Less context switching and less need to buy broad [Jira](https://www.atlassian.com/software/jira) access for support | Useful operational benefit; not separately quantified | The opportunity estimate behind the target was `15% of conversations × 80% realistically answerable = 12 percentage points`. At peak volume, the equivalent capacity could be larger, but that is still a capacity estimate. I would not call it annual savings unless finance confirmed that staffing or provider spend actually fell. ## The domain model decided what could be disclosed The answer was not “find the order.” It was “find the right order, for the right merchant, then explain only the facts that merchant may receive.” These were the concepts I had to keep consistent: | Concept | Type | Description | |---|---|---| | Merchant | Actor | The authenticated party asking through [Intercom Messenger](https://www.intercom.com/messenger). A merchant owns its orders and must not learn whether another merchant’s order exists. | | Order | Entity | The merchant-facing order identifier and source of order-level state. One order can relate to several internal packages. | | Package | Entity | An internal production unit linked to one order and one facility. The internal package ID is not a carrier tracking number. | | Production facility | System boundary | The place where a package is produced. Its status can explain a delay but is not itself a customer-facing authority. | | Order-context service | System | The read-only boundary between [Fin](https://fin.ai/) and internal systems. It authenticates the request, checks ownership, fetches permitted facts, and returns an explanation context. | | Relation index | Entity | A prepared mapping between conversation context, package IDs, and operational records. It lets the service find related context without asking the model to search systems. | | Operational or technical incident | Entity | A permitted summary from the separate [Intercom](https://www.intercom.com/) workspace or [Jira](https://www.atlassian.com/software/jira). The service may use the summary only after the order is authorized. | The vocabulary prevented a common category error: authentication established who was calling, but ownership established which order the caller could open. ```mermaid flowchart TD accTitle: Order context domain model accDescr: A merchant owns an order. An order contains packages made at production facilities. A read-only service checks that ownership before it combines order, tracking, and permitted incident context for Fin. Merchant[Merchant] --> Order[Merchant order] Order --> PackageA[Internal package A] Order --> PackageB[Internal package B] PackageA --> FacilityA[Production facility A] PackageB --> FacilityB[Production facility B] Order --> Service[Read-only order-context service] Service --> Fin[Fin in Intercom Messenger] ``` ## Ownership was the first trust boundary The person talking to [Fin](https://fin.ai/) was the merchant, not the merchant’s customer. The merchant could see its own order and delivery details, but should never be able to see another merchant’s order. The request carried two inputs with different trust levels. The following payload is illustrative; identifiers are redacted and the trust boundary is the production mechanism being shown: ```json { "authenticated_account_id": "merchant-account-123", "order_id_from_message": "123456789" } ``` The account ID came from the verified [Intercom Messenger](https://www.intercom.com/messenger) session. The order ID came from the merchant’s message and was extracted by [Fin](https://fin.ai/), so the service treated it as untrusted input. The service performed one lookup filtered by both values: ```text find order where order_id = order_id_from_message and merchant_account_id = authenticated_account_id ``` If the lookup returned nothing, [Fin](https://fin.ai/) received the same generic response whether the order did not exist or belonged to somebody else. Only a successful match allowed the service to retrieve packages and continue. ```mermaid sequenceDiagram accTitle: Ownership is checked before order context is fetched accDescr: Fin sends an authenticated merchant account and an untrusted order ID to the read-only service. The service checks both before retrieving any order or incident context. participant Merchant participant Fin participant Service as Read-only service participant Orders as Order data Merchant->>Fin: Ask about an order Fin->>Service: Account ID + order ID Service->>Orders: Match both values Orders-->>Service: Match or no match Service-->>Fin: Generic denial or permitted request Fin-->>Merchant: Explain only permitted facts ``` The plain-language rule was: knowing an order number is not proof that you may open it. [OWASP describes](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/) the underlying risk as Broken Object Level Authorization, but the practical defence was an ownership check on every request. At the time, the integration used [Intercom’s Messenger Identity Verification](https://www.intercom.com/help/en/articles/7946878-what-is-identity-verification-deprecated) with an [HMAC](https://developers.intercom.com/installing-intercom/web/identity-verification) because that was the available security mechanism. Today I would use [Intercom’s JWT-based Messenger authentication](https://www.intercom.com/changes/en/91608-a-new-secure-way-to-authenticate-messenger-users-with-jwts) for expiry, stronger control over signed attributes, and alignment with the current recommendation. The boundary stays the same: authentication does not replace resource ownership. ## Four sources became one explanation The company did not lack information. It lacked one controlled way to assemble the right information for the right merchant. | Source | What it contributed | Why it mattered | |---|---|---| | Internal order data | Order state, package state, original dates, and revised estimates | It supplied the structured facts already known by the company | | [AfterShip](https://www.aftership.com/docs/tracking) tracking data | Tracking events from the shipping integration | It explained what happened after a package left production | | Separate [Intercom](https://www.intercom.com/) workspace | Conversations between production facilities and operations | It added human operational context that support could not otherwise see | | [Jira](https://www.atlassian.com/software/jira) | Technical issues affecting an order or package | It explained failures such as a print file not reaching production | The service joined those sources only after the merchant and order had passed the ownership check. It then returned a small explanation context rather than forwarding raw records to the model. At the time, the relation index was maintained outside [Intercom](https://www.intercom.com/) because the service needed a dependable package-to-conversation lookup. Today I would first test whether [Intercom’s search and custom-attribute features](https://www.intercom.com/) could hold enough of that relation directly. If a separate index were still needed, I would evaluate [n8n Data Tables](https://docs.n8n.io/data/tables/) before reaching for a separate database for a small normalized index. I would keep a real database when stricter access boundaries, uniqueness guarantees, or heavier concurrency justified it. ## Disclosure happened before the model saw context The service did not fetch every incident and ask the model to decide what was safe. It checked a sensitivity field before reading full contents. The safe branch removed unnecessary personal data and returned a bounded summary. The following is a simplified representation of the production rule; the example keeps the mechanism while omitting implementation-specific details. ```text if source.sensitive == true: stop_before_fetching_full_contents() return "No safe operational detail available" context = fetch_permitted_source(source) context = redact_unnecessary_fields(context) return context ``` The important limitation was uncomfortable: the `sensitive` field defaulted to false, and a human had to turn it true. That meant an unclassified source could enter the permitted branch. The rule was deterministic, but the classification input still depended on human governance. Today I would keep the explicit field but add fail-closed treatment for known high-risk incident types. That trades some coverage and operations convenience for a smaller chance that an unclassified record is disclosed. I would make the decision with the process owner and security owner, not hide it inside a model prompt. ## Latency changed the shape of the design [Fin](https://fin.ai/) expected the tool to return quickly. That constraint changed what belonged in the live request: - The request path handled ownership, source reads, filtering, and one concise explanation context. - Package-to-conversation relation work happened asynchronously before the request needed it. - A slow or unavailable source produced a bounded answer or a human handoff, not a confident sentence assembled from half a response. This is a useful boundary for other systems too: do the work that makes the answer safe in the request path, and move expensive enrichment out of it when the user does not need to wait for that enrichment. ## Logging was another data-design decision The workflow execution history made troubleshooting possible, but it also became another place where operational context could exist. In a current version I would decide this alongside the data model: | Decision | Why it matters | |---|---| | Which fields may be logged | Debugging does not justify copying every source field into history | | Who may inspect executions | Troubleshooting access is still access to sensitive context | | How long logs remain available | Retention is part of disclosure risk, not an afterthought | The operational lesson is broader than this service: observability is a system boundary. Make it inspectable without making it an ungoverned second database. ## What generalizes beyond order status The pattern is not “put an AI agent in front of four systems.” It is a bounded read path: | Pattern element | Reusable decision | |---|---| | Identity | Authenticate the caller before looking up business data | | Ownership | Check access to the requested resource, not just the session | | Retrieval | Join only the sources needed for the question | | Disclosure | Filter and redact before the model sees context | | Failure | Return a useful boundary or handoff when a source is incomplete | | Action scope | Keep writes outside a read-only capability | This pattern fits when a reader needs a trustworthy explanation assembled from several systems and the capability can remain read-only. It does not fit when the core problem is a financial write, an irreversible action, or a workflow that needs long-running compensation and approval. Those need a different architecture, even if a model still helps with language. The related [architecture deep dive on governed skill libraries](/notes/how-to-build-a-shared-ai-skill-library-with-mcp) generalizes the same boundary-first reasoning to shared guidance rather than order context. ## Common questions ### Why was the service read-only? The problem was to explain an order, not change one. Read-only scope reduced the consequence of an incorrect model response and let the strongest controls focus on authentication, ownership, disclosure, and context assembly. Refunds, cancellations, and other writes need approval, idempotency, limits, audit trails, and recovery from partial writes. ### How did the service stop one merchant seeing another merchant’s order? It authenticated the request, checked that the order belonged to the merchant, and only then looked up related records. Authentication did not grant access to every order. Ownership was its own gate before source fetches and before the model saw context. ### What was the model actually allowed to see? Only the context needed to explain the order: structured order facts, tracking events, and permitted operational or technical summaries. Sensitive records were stopped before their contents were fetched, unnecessary personal data was removed, and the model never received credentials or unrestricted source access. ## The takeaway - The primary result was a measured 12-point change in support deflection; the financial translation is estimated capacity, not claimed realized savings. - The durable architecture was identity → ownership → permitted context → explanation, with writes outside the boundary. - The accepted trade-off was operational coverage versus stricter disclosure classification and latency. If your team is trying to turn fragmented operational context into a safe, useful read path, [message me](/#contact) and tell me what the system currently makes people assemble by hand. --- https://www.bartoszmroz.com/notes/about-me-and-my-work I did not learn AI architecture by starting with a model. I learned it by watching people keep real operations together with spreadsheets, chat messages, manual checks, and memory. My goal here is to explain the working principles that came from that experience: how I find the real system behind a process, decide what technology is allowed to do, and make the result safe enough to become part of somebody’s Tuesday. ## I learned the problem before I learned the stack I started close to frontline operations. At [Uber](https://www.uber.com/) in Kraków, then at [Kahoot!](https://kahoot.com/), I saw the work customers did not see: tracking updates, refunds, invoice adjustments, spreadsheets, and small pieces of context disappearing into [Slack](https://slack.com/) messages. Neither role was about designing platforms. The job was to get the work done. That taught me three things: - A process can look simple from the outside while asking a person to remember dozens of conditions every day. - The person doing the work often knows more about the real system than the diagram does. - Automation starts with ownership and exceptions, not with connecting two boxes. ## I started automating the hand-offs At [Gelato](https://www.gelato.com/), I began turning those observations into systems. I started with [Make](https://www.make.com/), automating support escalations from [Zendesk](https://www.zendesk.com/) into [Jira](https://www.atlassian.com/software/jira). Later I worked across [n8n](https://n8n.io/), [HubSpot](https://www.hubspot.com/), [Intercom](https://www.intercom.com/), and [Slack](https://slack.com/). The stack became more capable, but the consequences of a bad decision became more real too. A workflow could move a ticket quickly and still expose the wrong context. An integration could work in a demo and still fail when a field was missing or a permission changed. | A process looks like | The architecture question underneath | |---|---| | “Move this ticket to that queue” | Who is allowed to see the context, and what happens when the destination is unavailable? | | “Send this update automatically” | Which source is authoritative, and who owns the exception? | That is the part of automation I still find interesting: deciding what a connection is allowed to know, change, and do when the happy path ends. ## The support problem that changed my model of AI In one support environment, customers needed answers about orders, production, tracking, and technical issues. The context lived in several systems. The experience used [Fin](https://fin.ai/) inside [Intercom Messenger](https://www.intercom.com/messenger), but the model did not need—and should not have—broad access to all of them. The useful design was a narrow service behind the experience. It checked the merchant’s ownership of an order, assembled only the information that merchant was allowed to see, and gave the model a small, readable context. The AI had a modest job: explain the available facts. It was not asked to browse everything, invent an answer, or make an operational change. | Boundary | Deliberate limit | |---|---| | Read path | Check ownership and retrieve only permitted context | | Model | Explain available facts rather than search every system | | Action | Keep operational changes outside the model | That result became the [order-status case study](/notes/automating-order-status-for-print-on-demand). The durable lesson was not “add AI to support.” It was that a model becomes more useful when the surrounding system is clear about what it cannot do. ## The boundaries I look for first When I design an operational AI system, I start with four questions: 1. **Who is asking, and what do they own?** Identity and resource ownership come before retrieval. 2. **Is the task to explain, look something up, or change something?** Each step adds a different level of responsibility. 3. **What should the model see?** Fetch and filter sensitive context first; give the model the smallest useful representation. 4. **What happens when a dependency fails?** A timeout, missing field, or contradictory record is part of the design, not an embarrassing exception. The technology follows those answers. Sometimes the right solution is a workflow. Sometimes it is a secure internal tool or a custom connector. Sometimes the right answer is not to add AI at all. ## How I work with a team I am most useful when a process has become normal because nobody has had time to pull it apart. We start with the actual operating problem: where revenue leaks, where people repeat the same judgement, where a customer waits for context, or where a hand-off fails silently. | What I make explicit | Why it matters | |---|---| | Actors and domain objects | A system cannot enforce a boundary it has not named. | | Ownership and decision rights | Knowing a record exists is not the same as being allowed to use it. | | Allowed data and actions | A model should receive a bounded context, not a credential and a wish. | | Success criteria and guardrails | Speed is not a win if quality, safety, or customer trust falls. | After launch, I look at inspectability and recovery. A workflow nobody can inspect is a future incident. A model that cannot explain which context it saw is hard to trust. A process with no recovery route is not automated; it is outsourced to luck. ## Common questions ### What kind of work do you take on? I help teams turn operational knowledge into reliable workflows, internal tools, and bounded AI systems. Customer support, operations, and cross-system work are natural fits because the cost of missing context is easy to see. ### How do you use AI in a client system? I use it where language, classification, or summarisation genuinely helps. I keep deterministic rules, permissions, and consequential actions outside the model whenever possible. The model should have a clear job and a clear limit. ### What makes a project a good fit? You already own a real process or problem and want help making it easier to run, safer to scale, or less dependent on one person’s memory. A concrete, recurring pain is usually enough to begin. ## The short version - Start with the operating problem, not the model. - Name the actors, ownership rules, and failure paths early. - Give AI the smallest useful job and keep consequential control elsewhere. If this is the kind of problem you are trying to solve, [message me](/#contact) and tell me where the current hand-off breaks. --- https://www.bartoszmroz.com/services/support-priority-sprint # When support demand grows, find what can realistically change—and what your team still has to handle. I analyze up to 5,000 past conversations, then investigate the strongest recurring patterns in how issues are resolved, with input from the SMEs who own those processes. I weigh the volume involved, likely effort, and risk to customer outcomes to recommend one priority or a small set of priorities—or make the case for no action. **Within two weeks, you know where operational change could help with your most pressing support challenge—and where it may fall short.** [Book a 20-minute fit call](/#contact "contact:fit-call") We’ll check your support challenge, timing, data path, and mutual fit. No preparation or commitment. [Email me](/#contact "contact:email") ## When this sprint is useful This is for a Head, Director, or VP of Support, CX, or Customer Operations who has a concrete challenge to work through: demand is growing, the team is carrying too much repeat work, an important seasonal period is approaching, or a support outcome needs a clearer path than “try more automation.” Strong fit means four things are true: recurring work is visible in historical conversations; there is a real operating goal or decision horizon; you are not yet certain what deserves priority; and the people who know the shortlisted processes can validate what the evidence suggests. That can be an ecommerce team preparing for a high-volume period, or a SaaS team trying to make a recurring resolution path more dependable. It is not for a team that already knows exactly what to implement, needs immediate production delivery, wants a broad CX transformation or a predetermined AI or automation answer, or requires guaranteed capacity, service, customer-outcome, or financial results. ## The operating decision it supports The sprint helps you distinguish between recurring support work that could change and demand your team still needs to cover. The goal is not to force every conversation through a self-serve or automated route. It is to identify the narrowest changes that could improve how an issue is resolved without giving customer outcomes less weight. The result is a ranked decision: one priority, a small coordinated set of priorities, or a reasoned case for no action. It is not an unranked list of initiatives for your team to sort through later. ## Why ticket categories are not enough for this decision Good categorisation and reporting are useful infrastructure. They show what is arriving, how much of it there is, and where a trend deserves attention. This sprint asks a different question: how does a recurring issue actually reach its final resolution? A category cannot, on its own, show whether a customer received the resolution they expected, which step made the conversation wait, whether an SME decision is essential, or whether the same outcome could be reached through a clearer process, better content, existing platform configuration, or no change at all. I use the conversation history to investigate the resolution path behind the strongest recurring patterns, then validate that picture with the SMEs who own the process. That gives a CX leader a basis for deciding what to champion without dismissing the reporting they already have. ## How the sprint works ### 1. Find the patterns worth investigating I review the agreed sample of historical conversation threads and available ticket metadata. The purpose is to identify recurring issue and resolution patterns, including where conversations involve repeated touches, hand-offs, waiting, or an outcome the customer did not accept. ### 2. Test the operational path For the shortlist, I trace how the resolution is reached and speak with the SMEs who own or can authoritatively validate those processes. We separate a genuine dependency from a process that only looks complex in ticket data. ### 3. Make the priority defensible I weigh the volume involved, likely effort, and risk to customer outcomes at decision depth. This is not an implementation estimate, a causal forecast, observed savings, or a staffing conclusion. It is the evidence you need to decide what deserves attention now and what should wait. ## What you receive You receive two buyer-owned artifacts. - An executive decision deck gives you a clear recommendation, the alternatives considered, the trade-offs, and the decision it supports. - An evidence appendix gives you the underlying patterns, resolution-path reasoning, and the conditions that would change the recommendation, so you can discuss it with the relevant stakeholders without relying on a black-box conclusion. If the evidence supports no worthwhile change, you receive the same deck and appendix. They explain why no action is the right decision now, what to monitor, and what would justify revisiting it. ## What the recommendation may conclude The sprint is vendor-neutral. A recommendation may point to native platform configuration, a process or content change, a better-supported human resolution path, automation, custom implementation, monitoring, or no action. Where the right answer keeps work with your team, the issue may be process clarity, knowledge, permissions, or tools. It is not an assessment of individual teammate performance. Where automation is relevant, it is one possible means of delivering an already acceptable resolution—not a predetermined answer. ## Why work with me I started close to frontline operations and later worked at Gelato on support escalations and cross-system workflows. That experience taught me that moving a ticket quickly is not the same as resolving it well: context, permissions, ownership, and the exception path matter. You can see that approach in my [work on support hand-offs and operational boundaries](/notes/about-me-and-my-work) and in a [print-on-demand order-status case study](/notes/automating-order-status-for-print-on-demand). They are examples of my operator record, not consulting-client case studies or an endorsement from Gelato. ## Scope, participation, and data The standard sprint covers detailed analysis of up to **5,000 conversations** drawn from up to **90 days** of conversation history. Both limits apply together. For a larger population, we agree a sample or separate scope before the work starts. Native metadata outside that window can add context when available, but older conversations do not receive detailed analysis unless we agree that separately. The two-week clock starts after kickoff, once the agreed usable data is accessible and the required SME availability is scheduled. Your involvement is bounded: you join kickoff, a working shortlist review, and the final readout—about two to three hours in total. Up to two SMEs each join one short validation conversation. They own or can authoritatively validate the shortlisted processes; this is not broad employee research or individual performance assessment. Usable data means historical conversation threads and the available ticket metadata needed to compare recurring issues with their resolution paths. Relevant SOPs or process documentation are useful when they exist. Before the sprint, we agree the minimum data required, how I will access it, and when I will delete any working copies. The fixed engagement does not include production implementation, exhaustive SOPs, implementation-ready build specifications, ongoing reporting, or guaranteed capacity, service, customer-outcome, or financial results. An optional implementation with me is separately scoped. The sprint remains useful if you implement internally, choose another provider, monitor the issue, or take no action. ## Investment The standard Support Priority Sprint is **$2,000 USD**, fixed and paid upfront. It is designed to give you a clear basis for your next operational decision before committing a team to production change. If the detailed evidence needs to cover more than 5,000 conversations or more than 90 days of history, we agree the sample or separate scope first. [Book a 20-minute fit call](/#contact "contact:fit-call") We’ll check your support challenge, timing, data path, and mutual fit. No preparation or commitment. ## Common questions ### Is this an AI or automation audit? No. The sprint starts with the resolution path and the operating challenge, not a technology choice. Automation or AI may be a conclusion where it supports an acceptable resolution, but so may native configuration, process or content changes, a better-supported human path, monitoring, or no action. ### We already categorize conversations and report on Support—what is different here? Your categories and reporting remain useful. This work uses them as context, then investigates how recurring issues are actually resolved, what dependencies shape that path, and what can realistically change without treating customer outcomes as an afterthought. ### What happens if the evidence supports no worthwhile change? That is a valid outcome. You receive the decision deck and evidence appendix, including why the current path should stay in place, what to monitor, and the conditions that would make the issue worth reopening. ### Can Bartosz implement the recommendation? Yes, where there is a good fit, but implementation is a separate decision and separately scoped. The sprint is designed to stand on its own whether your team implements internally, works with another provider, or decides not to proceed. ### What data and access are required? We agree the minimum viable path before the sprint: historical conversation threads, available ticket metadata, and access to the process knowledge behind the shortlist. Relevant SOPs or documentation help when they exist. We also agree how access will work and when I will delete any working copies. ## Start with the challenge in front of your team If you need a clearer path through a pressing support challenge, bring the goal, timing, and the data path you can make available. I will help you determine what can realistically change before you ask your team to carry another initiative. [Book a 20-minute fit call](/#contact "contact:fit-call") We’ll check your support challenge, timing, data path, and mutual fit. No preparation or commitment.