<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SharpHaw</title>
    <description>The latest articles on DEV Community by SharpHaw (@sharphaw).</description>
    <link>https://dev.to/sharphaw</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4092541%2Fc8e37560-cff7-48e7-a84b-631364843a64.png</url>
      <title>DEV Community: SharpHaw</title>
      <link>https://dev.to/sharphaw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sharphaw"/>
    <language>en</language>
    <item>
      <title>The row that replaces nothing: how SharpOS connects a client's accounts without owning them</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:00:28 +0000</pubDate>
      <link>https://dev.to/sharphaw/the-row-that-replaces-nothing-how-sharpos-connects-a-clients-accounts-without-owning-them-3i43</link>
      <guid>https://dev.to/sharphaw/the-row-that-replaces-nothing-how-sharpos-connects-a-clients-accounts-without-owning-them-3i43</guid>
      <description>&lt;p&gt;Open r/PPC or r/GoogleAnalytics on any given week and the same fight is running: an agency that "refuses to provide access" to the Google Ads account, a business owner who discovered the agency "took billing ownership of the account, even though I never gave permission", and the calm reply underneath from someone who has seen it too often — the business should own the account and the agency should sit inside it as a manager. Every client already has the accounts. The only real question is who holds them.&lt;/p&gt;

&lt;p&gt;In part 1 of this series I called Integrations the row on our replacement ledger that replaces nothing. This is that row, paid in full: what SharpOS connects to, why it deliberately owns none of it, how the connection layer is built, and the five providers it cannot listen to. If you build anything multi-tenant that has to act inside a customer's accounts, the owning-nothing part is the bit worth stealing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; SharpOS connects a client's Google, Cal.com, Resend, WhatsApp, Facebook, ElevenLabs and model accounts so automations and agents can act through them. The accounts stay the client's, the tokens sit with a broker keyed to the client's organisation, and the workspace stores one row that says "connected, as whom".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "replaces everything" is the wrong promise for this row
&lt;/h2&gt;

&lt;p&gt;The all-in-one pitch usually ends with a login count: one place for calendar, mail, messaging, forms, the lot. For most rows on our ledger that promise is honest, because the thing being replaced is a tool the engagement uses internally. Nobody outside the engagement cares whether the weekly queue lives in Trello or in Boards.&lt;/p&gt;

&lt;p&gt;Calendars, mail delivery, WhatsApp numbers and model accounts are different. They are shared with the rest of the client's business, they carry the client's history, and in the Google cases they are the accounts the client will fight an agency over. A workspace that "replaces" them has either rebuilt Google Calendar badly or quietly moved the client's accounts under its own name. The first is a waste. The second is the hostage situation the r/PPC threads describe, with better UI.&lt;/p&gt;

&lt;p&gt;So row 12 says connect, and it means it. The client keeps paying Google, Cal.com, Resend, Meta, ElevenLabs, OpenAI or OpenRouter. SharpOS gets a grant to act, scoped to one organisation, revocable from a card, and no more. Connect is a grant. Replace is a custody transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Integrations does instead
&lt;/h2&gt;

&lt;p&gt;Integrations is one page in the hub, gated on the Integrations feature flag and on the manager role. Viewers and editors never see it. A manager connects a provider from a card grid: OAuth providers go through a hosted consent screen and come back to the same page; key-based providers (Resend, ElevenLabs, OpenAI, OpenRouter) take the key in a dialog. Once a provider is active, its actions and any triggers it has become available to three consumers at once: the Automations canvas, the REST API, and the MCP server agents use.&lt;/p&gt;

&lt;p&gt;Today the grid holds Google Calendar, Google Sheets, Google Search Console, Google Analytics, Google Ads, Google Maps, Reddit, Hashnode, Dev.to, Resend, Facebook Pages, WhatsApp, ElevenLabs and Cal.com, plus the four social providers that predate the shared catalogue (Instagram, X, LinkedIn, GitHub), plus an AI providers card for OpenAI and OpenRouter keys, and the org's own SharpOS API and MCP credentials. Meta Ads has its own read-only grant and belongs to the Ads surface, so I'll leave it there.&lt;/p&gt;

&lt;p&gt;You can walk through the page as a fictional client here: &lt;a href="https://sharphaw.digital/showcase/foss-gray/integrations" rel="noopener noreferrer"&gt;showcase → Integrations&lt;/a&gt;. Nothing you click leaves the browser. The feature page is at &lt;a href="https://sharphaw.digital/features/integrations" rel="noopener noreferrer"&gt;sharphaw.digital/features/integrations&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A broker holds the tokens, keyed to the organisation.&lt;/strong&gt; Provider connections run through Composio as the credential broker. It custodies the OAuth tokens and API keys, and every connected account is keyed to exactly one organisation. SharpOS never stores a provider's OAuth token; it stores a connection record: which provider, which organisation, its status (connecting, active, invalid or revoked), and the account it runs as, so the card can show who it acts as. When an automation node runs, the backend loads the organisation's active connection and executes the provider action as that organisation, so there is no path from one org's workflow to another org's calendar.&lt;/p&gt;

&lt;p&gt;The one exception is the AI providers card. OpenAI and OpenRouter keys are stored in SharpOS, encrypted, because the backend runs inference itself and a broker that proxies actions cannot proxy a streaming chat. Status reads expose a yes/no and an account label; the raw key never leaves the backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bad keys never persist.&lt;/strong&gt; Pasting an API key triggers one real call against the provider. If it fails, the broker account is deleted and the save is refused, so a wrong key cannot sit there marked active for a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Triggers are connection-scoped, and some providers have none.&lt;/strong&gt; Google Calendar triggers are subscribed at connect time and delivered to one signed webhook. Cal.com offers no triggers through the broker, so the connection mints an unguessable address and a signing secret, registers a single Cal webhook carrying all four booking events, and retries after 1, 5 and 30 minutes if registration fails. The connection stays active throughout; a flaky registration should never take the action nodes down with it. Every inbound delivery is verified, matched against an active connection before any event fires, and deduplicated for 30 days. A "calendar event created → create calendar event" workflow cannot ping-pong itself, because created ids are remembered and their echoes dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents get strict inputs.&lt;/strong&gt; The REST and MCP surfaces for these providers use strict schemas: a misspelled field from an agent caller is rejected outright, never silently dropped. A DEV post this month told the story of an agent that "posted successfully. To the wrong account." The connection model above is our answer to the first half; strict inputs are the answer to the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;Five providers cannot be listened to. Reddit, Search Console, Google Analytics, Google Ads and Google Maps expose no usable trigger, so "when X happens" is built the other way round: a schedule drives the query node and downstream steps branch on the result. Google Sheets has triggers in theory, but each one needs a spreadsheet id at subscription time, which a connect-time subscription cannot supply.&lt;/p&gt;

&lt;p&gt;Google Ads has no reporting here, and no proxy escape hatch either. X and LinkedIn are text-only through the broker. Google Analytics reports go through a request proxy because the toolkit only covers the admin API. Connect, disconnect and provider selection are hub operations, on purpose; there is no REST endpoint that connects a client's Google account on their behalf.&lt;/p&gt;

&lt;p&gt;And there is no marketplace. Twenty-odd providers, chosen because a SharpHaw engagement needs them, sitting on a broker that is a platform prerequisite. The fair objection is that I've moved the dependency rather than removed it, and that's true: if the broker vanished tomorrow, every connection would need re-consenting through whatever replaced it. The client's accounts, though, would be exactly where they were, and that is the property I care about. If your business needs three hundred connectors and a directory to browse, keep a general iPaaS. This layer exists so a client's automations and agents can act inside the accounts the client already owns, and it stops where that job stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the SharpHaw service
&lt;/h2&gt;

&lt;p&gt;Integrations is the plumbing under &lt;a href="https://sharphaw.com/services/ai-automations" rel="noopener noreferrer"&gt;AI Automations&lt;/a&gt;: a booking lands in Cal.com, a workflow reads the calendar, an email goes out through the client's Resend domain, a WhatsApp template confirms it. The client sees all of it in their own workspace, as every SharpHaw subscription includes &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;SharpOS&lt;/a&gt;. What they never see is a request to hand over an account.&lt;/p&gt;

&lt;p&gt;The question I'd put to anyone building the same layer: where do you draw the line between what your product should connect to and what it should absorb, and has a customer ever disagreed with you about which side something belonged on?&lt;/p&gt;

&lt;p&gt;This is part of &lt;code&gt;SharpOS, surface by surface&lt;/code&gt;. Part 1, the ledger and the decision behind it: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;We replaced a 12-tool client stack with one workspace we built&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>showdev</category>
      <category>automation</category>
    </item>
    <item>
      <title>We built a support widget with no human handoff. Here's why that's the honest version.</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Sun, 06 Sep 2026 14:00:26 +0000</pubDate>
      <link>https://dev.to/sharphaw/we-built-a-support-widget-with-no-human-handoff-heres-why-thats-the-honest-version-51b4</link>
      <guid>https://dev.to/sharphaw/we-built-a-support-widget-with-no-human-handoff-heres-why-thats-the-honest-version-51b4</guid>
      <description>&lt;p&gt;Every agency has put a chat bubble on a client's site and then watched what happens to it. The client is a six-person firm. Nobody is assigned to the inbox. A visitor types "do you do Saturday appointments?" at 21:40, gets "an agent will be with you shortly", and leaves. On Monday somebody notices the queue.&lt;/p&gt;

&lt;p&gt;That is the scene I kept coming back to when we built Support, the chat widget inside SharpOS. This series walks our workspace one surface at a time; this part is about the row on our ledger that stands in for Intercom and Zendesk, and about the feature we deliberately left out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Support answers from a knowledge base the client controls, cites what it used, and says plainly when it can't help. There is no human handoff, on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a helpdesk is for, and where it strains
&lt;/h2&gt;

&lt;p&gt;Intercom and Zendesk are built around a team: seats, queues, assignment, a shared inbox across email, chat and social. If you have people whose job is answering, that shape is right. An AI layer deflects the repetitive questions and the humans take the rest.&lt;/p&gt;

&lt;p&gt;The businesses we work with have no "rest". The owner is the support team, and the owner is also the sales team, the operations team and the person who signs the invoices. Give them a shared inbox and the bubble on their site becomes a promise the business can't keep. The handoff is the feature everyone lists first and nobody staffs.&lt;/p&gt;

&lt;p&gt;I want to be precise about that: it's a statement about a buyer, not about the tools. A team with an inbox rota should keep its helpdesk.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Support does instead
&lt;/h2&gt;

&lt;p&gt;Support is a chat bubble the client embeds on any site with one script tag. Visitors ask, an assistant answers from the organisation's knowledge base, and every reply cites the entries it used. When the knowledge base can't back an answer, the assistant doesn't improvise; it shows a fallback message the client wrote, with their WhatsApp, email or phone appended. The unanswered question is filed as a suggestion in the hub, where it becomes a knowledge entry in one click. Conversations land in a read-only inbox in the workspace with an AI title, a summary and the visitor's mood. Clients without a site to embed on get a hosted help page instead, and the widget ships its UI in seven European locales while the assistant replies in whatever language the visitor writes.&lt;/p&gt;

&lt;p&gt;The knowledge base is one table: authored Q&amp;amp;A, plus one row per item a connected source contributes. Sources can be a Page, a URL, or a Board column. That last one matters for us — a client's Blog board, Published column, becomes part of what the assistant knows, one document per card, kept in sync as cards are edited, moved or archived.&lt;/p&gt;

&lt;p&gt;You can poke at the inbox in the &lt;a href="https://sharphaw.digital/showcase/arbor/support" rel="noopener noreferrer"&gt;showcase workspace&lt;/a&gt;, fixtures only, nothing leaves your browser. The &lt;a href="https://sharphaw.digital/features/support" rel="noopener noreferrer"&gt;feature page&lt;/a&gt; has the walkthrough.&lt;/p&gt;

&lt;p&gt;Inside the organisation it follows the rules every SharpOS surface follows. The Support feature flag is off by default and enforced server-side. Editors read the inbox, managers configure the assistant. Once the Analytics flag is on too, Analytics gains a Support tab, read from daily rollups rather than by scanning conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The install is the boring part, and boring is the point: one script tag on the client's site, carrying the widget's id, and the bubble is live.&lt;/p&gt;

&lt;p&gt;The script mounts the chat in a frame served from our domain, so the widget's styles never collide with the host page's, and the host page opens no live connection until the visitor actually opens the chat. The script also gives the host page a small set of controls, open the widget, send a message, say who the visitor is, listen for a reply, and it only accepts those commands from the page that embedded it, and only posts visitor text back to that same page.&lt;/p&gt;

&lt;p&gt;Two thresholds decide what the assistant is allowed to say. Each published Q&amp;amp;A is indexed twice: once as the question alone, so an exact question scores near 100%, and once as the full Q&amp;amp;A context. A match at or above the answer threshold (70% by default) grounds the reply. A best match below the capture threshold (60% by default) triggers the fallback and records the question as a suggestion for the knowledge base. Between the two it falls back quietly. The two dials are set per organisation, and the pair is ordered by construction, so no score can both answer and file a gap.&lt;/p&gt;

&lt;p&gt;The part I'd defend hardest is the read-side check. A Page or a Board card can be archived, made internal, or deleted after it was embedded, and the search index is a derived copy that none of those paths write to. So before any source match can ground a public reply, the live page, board, column and card are re-checked, and a match that can't be proven serveable is dropped, including when the check itself fails. The widget degrades to the fallback rather than answering from a copy it can't verify.&lt;/p&gt;

&lt;p&gt;The rest is the guardrails an unstaffed bubble needs. Visitor sessions are bound to the site that embedded the widget with a short-lived token and a domain allow-list. A visitor gets 8 messages a minute and 200 a conversation. An optional daily budget swaps every model call for a "high demand" message when it runs out. It all runs on the client's own OpenAI or OpenRouter key, held in their organisation, and the inbox shows an estimated cost per conversation. We don't sit between the client and their model bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;It won't hand a conversation to a person. There is no agent seat, no queue, no "someone will be with you". If a customer's question needs judgement (a refund, a dispute, a change to an account), the assistant's job is to say so and give the contact channel, and the client's job is to answer that email the way they would have anyway.&lt;/p&gt;

&lt;p&gt;It isn't a multichannel inbox; email, social DMs and phone don't flow into it. And it won't answer outside the knowledge base, which means an empty knowledge base produces a polite bot that mostly says "get in touch". That's correct behaviour until the base is filled, and we fill it from the Pages and Boards the engagement already produces.&lt;/p&gt;

&lt;p&gt;If you have a support team, or your product generates conversations that genuinely need a human in the loop, keep Zendesk or Intercom. This row of the ledger is for the business that never had a support team and shouldn't pretend to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the service
&lt;/h2&gt;

&lt;p&gt;Support ships as part of SharpHaw's &lt;a href="https://sharphaw.com/services/ai-automations" rel="noopener noreferrer"&gt;AI Automations&lt;/a&gt; work: the knowledge base is built from the client's own pages and boards, automations react to events such as conversation started and visitor identified, and captured questions feed back into the content we write next. It's included in the &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;SharpOS&lt;/a&gt; workspace every subscription runs in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I'd like answered
&lt;/h2&gt;

&lt;p&gt;If you've shipped an AI-only support surface, where did you draw the "I can't help with that" line, and did your visitors respect it? I'm especially curious whether anyone has numbers on abandonment after a fallback message versus after a queue.&lt;/p&gt;

&lt;p&gt;This is part of &lt;code&gt;SharpOS, surface by surface&lt;/code&gt;. Part 1, on the whole ledger: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;We replaced a 12-tool client stack with one workspace we built&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your client's Zap stopped running and nobody noticed. We built Automations around that failure.</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:00:26 +0000</pubDate>
      <link>https://dev.to/sharphaw/your-clients-zap-stopped-running-and-nobody-noticed-we-built-automations-around-that-failure-2a07</link>
      <guid>https://dev.to/sharphaw/your-clients-zap-stopped-running-and-nobody-noticed-we-built-automations-around-that-failure-2a07</guid>
      <description>&lt;p&gt;There's a thread on r/zapier that every agency person recognises on sight. One Zap per client, about thirty clients, and the Zaps have started not running. No error. The poster is doing manual data transfers for ten of the thirty. Further down someone asks the real question: &lt;em&gt;"how do you catch a client's Zap that silently stops running? No error — just quiet."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run SharpHaw, a small agency in Lisbon, and I built SharpOS, the workspace we run every client engagement in. Automations is row 9 of its replacement ledger, the row that stands in for Zapier and n8n. This post is about what it does, how it's built, and the part of that row we deliberately didn't try to replace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; inside a client engagement the trigger that matters is a card moving on a board, and the failure that matters is one somebody reads. We built the engine on the workspace's own event bus and made a failed run something the hub shows you, then accepted a much shorter connector list as the price.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Zapier and n8n are for, and where they strain
&lt;/h2&gt;

&lt;p&gt;Both are general-purpose integration platforms: a trigger in one app, an action in another, thousands of connectors in between. That is exactly the right shape when your events live in other people's systems. A Stripe payment, a Typeform response, a row in a sheet. If that's your problem, you already have the right tool, and nothing below argues otherwise.&lt;/p&gt;

&lt;p&gt;The strain shows up when the thing you're automating is the engagement itself. The events that matter in our work are workspace events: a card moved to Approved, a brief page created, a form on the client's site submitted, an audit finding opened, a support conversation left unanswered. To automate those from outside you first have to export them, which means a webhook per event type and a mapping layer that drifts every time a field is renamed. And when it breaks, it breaks in a tool the client has never logged into. Both platforms can alert on a failed run if you wire that up; the question is who receives the alert and whether they read it. In practice the first person to notice is the client, weeks later, asking why the follow-up email never came.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Automations does instead
&lt;/h2&gt;

&lt;p&gt;Automations is a workflow canvas inside the org: trigger nodes, optional filter and branch nodes, action nodes. The triggers are the hub's own events (boards, customers, pages, forms, audits, studio, support), plus GitHub, Google Calendar and Cal.com events, inbound webhooks, and schedules. Actions create or update cards, customers, pages and studio frames, send email, call an HTTP endpoint, publish to Instagram, X and LinkedIn, and act through whatever the organisation has connected under Integrations. Data moves between steps with a &lt;code&gt;{{variable}}&lt;/code&gt; templates, and a node's output is also addressable by its name, so a request named "Webhook Call" exposes the previous node's status downstream.&lt;/p&gt;

&lt;p&gt;It's org-scoped and role-gated like everything else in the workspace: the feature flag is off by default, building and enabling is owner, manager or platform admin, and any member can read run history. A workflow runs as a named user and every action node re-checks that user's permissions at execution time, so a workflow cannot do something its author couldn't. You can click around a fixture-only version at &lt;a href="https://sharphaw.digital/showcase/vell/automations" rel="noopener noreferrer"&gt;showcase/vell/automations&lt;/a&gt;; the feature page with the walkthrough is at &lt;a href="https://sharphaw.digital/features/automations" rel="noopener noreferrer"&gt;sharphaw.digital/features/automations&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One owned example: this series ships through it. When a card on our Dev.to board is approved, the board's own automation publishes the card body through the connected DEV account and writes the live URL back onto the card. Nobody pastes anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Three decisions carry most of the weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The engine listens to the hub itself, and listening is cheap.&lt;/strong&gt; Every change in the hub — a card moved, a form submitted, a customer created — is checked against a small table of what the organisation's workflows actually subscribe to. If nothing subscribes, nothing is written. If something does, an event is recorded and one run is queued per matching workflow; each run walks the graph from the trigger, fills in the variables, records a step per node and follows the edges. Filters stop a path, branches choose one.&lt;/p&gt;

&lt;p&gt;That subscription table earned its keep on schedules. The per-minute tick used to load every workflow with a schedule trigger and evaluate its timing in code: 386 documents and 222 KB per tick, 1,440 ticks a day, almost always to fire nothing. Copying each workflow's schedule onto its subscription row, in the same write that saves the workflow so the copy can't drift, turned the tick into a prefilter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every run is a record, and a failed run has read state.&lt;/strong&gt; Each run stores its trigger payload and one step per node, with a redacted input and the raw output, so the run detail shows exactly what became the next node's variables. Runs can be replayed. A failed run is marked as read the first time a member opens it, and until then it sits in the hub home's Needs attention list, one click from the run detail. First read wins: once anyone on the team has seen it, the organisation has. That is the design answer to the r/zapier question. The Zap that quietly stops is invisible because nothing owns the failure; here the failure is a row on the same screen the client opens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks are boring on purpose.&lt;/strong&gt; An inbound webhook trigger gets an unguessable address and a signing secret that is hashed on the server and shown once. Only diagnostic headers are kept; authorisation headers, cookies and API keys are stripped at ingest and never become variables or run history. The node's panel shows the last real payload received and a copy-as-cURL button, which is most of what you actually need when wiring a third party in.&lt;/p&gt;

&lt;p&gt;Agents build workflows over the same API a person uses through the canvas: create the workflow, validate it (a trigger present, known node types, valid configuration, connected edges, no cycles), then enable it. A form's answers arrive as variables named after the field labels, and a blank optional answer renders empty rather than leaving a stray placeholder in your payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;The connector list is short. Automations acts through the providers an organisation has connected under Integrations, which today is around fifteen, against the thousands a general platform offers. If your trigger lives in a system we don't connect, or your action does, Zapier or n8n is the right tool and we'll happily point a webhook at it in both directions.&lt;/p&gt;

&lt;p&gt;Runs and events are pruned after 30 days, so it is an operational log, not an archive. There's no marketplace of prebuilt templates; a workflow starts from the canvas or from the API. And the engine assumes the workspace is where the work happens, which is the bet the whole product makes. If your team's real board lives elsewhere, the triggers you care about won't be here.&lt;/p&gt;

&lt;p&gt;Who should keep n8n: anyone who wants to own the engine itself, run it on their own box, and connect anything. That's a legitimate preference and it isn't the one we made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the service
&lt;/h2&gt;

&lt;p&gt;Automations is the surface behind SharpHaw's &lt;a href="https://sharphaw.com/services/ai-automations" rel="noopener noreferrer"&gt;AI Automations&lt;/a&gt; work: we map a client's recurring time sinks and wire the ones that belong to the engagement here, next to the boards and forms that produce the events. It ships inside &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;SharpOS&lt;/a&gt; with every subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  A question for the people running client automations
&lt;/h2&gt;

&lt;p&gt;If you've run automations for clients, where did the silent failures actually surface: in the tool, in your inbox, or in the client's complaint? I'd like to know whether the read-state approach would have caught yours.&lt;/p&gt;

&lt;p&gt;This is part of &lt;em&gt;SharpOS, surface by surface&lt;/em&gt;. Part 1, the whole ledger and the row we couldn't replace: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;We replaced a 12-tool client stack with one workspace we built&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>n8n</category>
    </item>
    <item>
      <title>We put a link-in-bio page inside every client workspace. Here's the click pipeline behind it.</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Sat, 05 Sep 2026 14:00:27 +0000</pubDate>
      <link>https://dev.to/sharphaw/we-put-a-link-in-bio-page-inside-every-client-workspace-heres-the-click-pipeline-behind-it-48d2</link>
      <guid>https://dev.to/sharphaw/we-put-a-link-in-bio-page-inside-every-client-workspace-heres-the-click-pipeline-behind-it-48d2</guid>
      <description>&lt;p&gt;Open the Instagram profile of almost any small business we talk to and the bio link goes to a page on somebody else's account. Often the freelancer who set it up two years ago. The colours are close to the brand, the buttons point at the right places, and when the owner asks where the clicks come from, the honest answer is a screenshot from a dashboard they can't log into. One marketer on r/marketing put the whole problem in a sentence: &lt;em&gt;"I am trying to get more information about the people clicking on my linktree link, such as where does the people clicking are coming from."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I'm Gabriel, founder of SharpHaw, and this is the second post in a series about SharpOS, the workspace we run every client engagement in. Part one covered the replacement ledger as a whole. This one takes row 11: Spotlight, our link-in-bio surface, and mostly the click pipeline underneath it, because that's the part I'd want to read about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; the link page is the smallest public thing a client owns and the first thing they hand to a third party. Spotlight puts it inside the client's workspace, on their Brand Kit by default, with clicks counted next to everything else we ship for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a link-in-bio page is for, and where a hosted one strains
&lt;/h2&gt;

&lt;p&gt;Instagram won't linkify a URL in a post, so "link in bio" became the one door out of the app, and Linktree and Bio.link built good products around that door. Unlimited links, a theme picker, an analytics tab, a URL you can put on a business card. For a creator running their own account, that's the right tool and I'd tell them to keep it.&lt;/p&gt;

&lt;p&gt;Inside a client engagement the same page strains in three places. The brand gets retyped into a theme editor, so the moment the client's colours change, the link page quietly doesn't. The analytics live in an account that belongs to whoever created it, which is why "where do the clicks come from" turns into a screenshot. And the page is the client's front door on the one channel they post to every day, held in a tool nobody in the business owns. A comment on r/SaaS about Linktree's own position says the quiet part: &lt;em&gt;"you don't collect a ton of data about your customers and worst of all it's entirely dependent on Meta's platform."&lt;/em&gt; For the vendor that's a business risk. For the client it's their door.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Spotlight does instead
&lt;/h2&gt;

&lt;p&gt;Spotlight gives each organisation one or more public, branded link pages at a short public address, built in the hub with three tabs: Edit, Design, Share. Links can be grouped, given a subtitle and a real brand icon, dragged into order. Nothing exotic. The part that matters is what happens when nobody opens the Design tab.&lt;/p&gt;

&lt;p&gt;Unset means inherit. A colour, font or corner radius left empty resolves from the organisation's Brand Kit before it falls back to a default, so a page nobody styled is already on-brand, and a Brand Kit edit reaches every such page with no per-page action. Even the logo inherits: a page with none of its own serves the kit's wordmark, the dark-surface mark when the page runs dark. Where the kit's colour names don't match the heuristic, a slot can be bound to a named kit entry and it stays read-only until unbound.&lt;/p&gt;

&lt;p&gt;Every page has its own analytics view (clicks, views, unique visitors, click-through rate, a ranked list per link), and the organisation's Analytics page has a Spotlight tab that rolls all pages up with source, device, country and campaign breakdowns. Access follows the standard model: &lt;code&gt;internal&lt;/code&gt; pages are agency-only, &lt;code&gt;client&lt;/code&gt; pages are visible to every member, only platform admins flip that, and click analytics are editor-and-above whichever the page is. The whole surface sits behind a feature flag, off by default. If the organisation has saved tracking codes under Integrations, the hosted page carries them with a consent prompt. You can open a real one here: &lt;a href="https://sharphaw.digital/showcase/mara-lindqvist/spotlight" rel="noopener noreferrer"&gt;a fixture workspace's Spotlight surface&lt;/a&gt;, browser-only, nothing persisted. The feature page is at &lt;a href="https://sharphaw.digital/features/spotlight" rel="noopener noreferrer"&gt;sharphaw.digital/features/spotlight&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the click tracking works
&lt;/h2&gt;

&lt;p&gt;The public page renders each button as a real link that opens in a new tab. There is no redirect in the path. One view is recorded per session when the page loads, and each button press sends a tiny click beacon to our own origin, addressed by the page's permanent token rather than its slug — so a client can rename the slug and every deployed embed keeps reporting. Navigation proceeds whether or not the beacon lands.&lt;/p&gt;

&lt;p&gt;Attribution keys off a stable per-link id, never the link's position in the list, so reordering or editing a link never corrupts its history. Each event is one append-only record; nothing is read and then updated, so a burst of clicks produces no contention. Every five minutes the raw events are rolled up into a daily table, clicks, views and unique visitors per link and per page, which is what the dashboard reads. Raw events are pruned after 90 days; the daily aggregates are kept forever.&lt;/p&gt;

&lt;p&gt;The decision I'd defend hardest is keeping two identities apart. Abuse control uses an opaque key derived from the edge's trusted network address, and only that key is accepted for rate limiting. Analytics uses a random visitor id generated in the browser and hashed together with the page id before it is stored. Rotating the visitor id cannot allocate more limiter rows, and the page-wide capacity check runs before any per-client row exists, so an attacker who has filled a page cannot grow persistent state by inventing ids. Country and device come from the edge's own request headers, which the browser can't forge; referrer and UTM come from the browser and are reduced to a bare host or an allow-listed value and never rendered as a link. No IP, no coordinates, no raw user agent is stored. Breakdowns count views rather than clicks, because the clicks after an arrival came from the same place and counting them again would flatter the source.&lt;/p&gt;

&lt;p&gt;A click also emits an event into Automations, and the whole page model is reachable over REST and MCP, where going live is deliberately two switches: publish the status, then enable the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;Say the limits before anyone else has to. The page lives on our domain today, not the client's. There is no storefront, no payment block, no email capture, no marketplace of add-ons. Unique visitors are a sum of daily uniques and are marketing-grade, not billing-grade; a determined actor can inflate the visitor count even though the ceilings bound what gets accepted. And there is no tracked redirect yet, so a click from a no-JS context or an email prefetcher isn't counted. If a client sells through their bio link, or needs the page on their own domain this quarter, Linktree or Bio.link is still the right answer and I'll say so on the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the service
&lt;/h2&gt;

&lt;p&gt;Spotlight belongs to the Content Engine side of what SharpHaw does: the bio link is where the content we ship for a client gets distributed on the channel they post to daily, and now the clicks land in the same workspace as the posts. The service is described at &lt;a href="https://sharphaw.com/services/content-engine" rel="noopener noreferrer"&gt;sharphaw.com/services/content-engine&lt;/a&gt;, and SharpOS itself, the workspace all of this runs in, at &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;sharphaw.com/sharp-os&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One question for anyone who has built click tracking on a public page: how do you keep the identity you rate-limit on separate from the identity you count on, and did you end up with a redirect anyway?&lt;/p&gt;

&lt;p&gt;This is part of the &lt;code&gt;SharpOS, surface by surface&lt;/code&gt; series. Part 1, on the whole ledger: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;We replaced a 12-tool client stack with one workspace we built. Here's the row we couldn't.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>startup</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Route Gemini 3.8 Flash by risk, not model rank</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Sat, 05 Sep 2026 14:00:25 +0000</pubDate>
      <link>https://dev.to/sharphaw/route-gemini-38-flash-by-risk-not-model-rank-2kd9</link>
      <guid>https://dev.to/sharphaw/route-gemini-38-flash-by-risk-not-model-rank-2kd9</guid>
      <description>&lt;p&gt;Gemini 3.8 Flash is capable enough that “Flash for easy work, Pro for hard work” is no longer a useful router.&lt;/p&gt;

&lt;p&gt;Google positions the GA model for long-horizon software engineering, autonomous agents, and complex enterprise workflows. The practical question is no longer whether Flash can attempt the task. It is whether your workflow can detect and contain a bad attempt.&lt;/p&gt;

&lt;p&gt;Here is the risk router I would start with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the job before the model
&lt;/h2&gt;

&lt;p&gt;Give every agent step four properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;reversible&lt;/code&gt;: can you undo the result cheaply?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;externalSideEffect&lt;/code&gt;: does the next tool call affect a customer, money, permissions, public content, or production data?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;acceptanceTest&lt;/code&gt;: can software check the output?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;verificationMinutes&lt;/code&gt;: how much human time does a clean result still need?
The model comes after those answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thresholds are placeholders. Measure them on your own queue. The shape of the decision is the useful part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put Flash on the bounded path
&lt;/h2&gt;

&lt;p&gt;Gemini 3.8 Flash is a good default when the output stays a draft or has a cheap mechanical check.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;extract fields and validate them against a schema;&lt;/li&gt;
&lt;li&gt;classify records into an existing taxonomy;&lt;/li&gt;
&lt;li&gt;draft code behind tests and branch protection;&lt;/li&gt;
&lt;li&gt;summarise documents with source links;&lt;/li&gt;
&lt;li&gt;rewrite copy inside explicit length and vocabulary rules;&lt;/li&gt;
&lt;li&gt;compare records and flag exceptions without changing them.
These jobs are not necessarily simple. They are bounded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google’s model page lists a 1,048,576-token input limit, a 65,536-token output limit, function calling, structured output, code execution, and search grounding. That makes the model a credible worker inside a tool-rich system. None of those features decides whether the next tool call is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Escalate before the side effect
&lt;/h2&gt;

&lt;p&gt;Do not wait for a low-confidence answer after the action has happened.&lt;/p&gt;

&lt;p&gt;Put the gate immediately before the workflow sends, publishes, pays, deletes, grants access, or mutates production state.&lt;/p&gt;

&lt;p&gt;The larger model receives the failed attempt and the validation errors. That is better than a blind retry with more tokens. A person receives only work whose consequences justify the interruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep thinking level inside the route
&lt;/h2&gt;

&lt;p&gt;Gemini 3.8 Flash supports low, medium, and high thinking. Medium is the default, and &lt;code&gt;minimal&lt;/code&gt; is unsupported.&lt;/p&gt;

&lt;p&gt;Treat thinking level as effort within a route:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;low for latency-sensitive, tightly specified work;&lt;/li&gt;
&lt;li&gt;medium for the normal bounded path;&lt;/li&gt;
&lt;li&gt;high for a difficult step that still remains safe to attempt with Flash.
Thinking level does not replace authorisation, validation, idempotency, or rollback. Those are workflow properties.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Measure cost per accepted result
&lt;/h2&gt;

&lt;p&gt;Input and output token prices are useful, but they miss the expensive part of many agent systems: review and repair.&lt;/p&gt;

&lt;p&gt;Track these four numbers for each route:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;first-pass acceptance rate;&lt;/li&gt;
&lt;li&gt;human review minutes;&lt;/li&gt;
&lt;li&gt;escalation rate;&lt;/li&gt;
&lt;li&gt;escaped-error rate.
Then calculate total cost per accepted result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If Flash halves inference spend and doubles review time, the route got more expensive. If it handles most of the queue and cleanly escalates the exceptions, the architecture is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the exception list
&lt;/h2&gt;

&lt;p&gt;Gemini 3.8 Flash raises the baseline for fast, cost-efficient agents. Use that advantage on work your system can observe.&lt;/p&gt;

&lt;p&gt;Write the stop conditions before the prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;missing or contradictory input;&lt;/li&gt;
&lt;li&gt;policy conflict;&lt;/li&gt;
&lt;li&gt;validation failure;&lt;/li&gt;
&lt;li&gt;high-impact side effect;&lt;/li&gt;
&lt;li&gt;repeated uncertainty;&lt;/li&gt;
&lt;li&gt;verification cost above your threshold.
The model choice will keep changing. A router built around reversibility, blast radius, ambiguity, and verification cost will survive the next release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What condition in your current agent should trigger escalation but does not yet?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We built a Canva-shaped editor into our client workspace. Here's what we left out on purpose.</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Sat, 05 Sep 2026 08:00:26 +0000</pubDate>
      <link>https://dev.to/sharphaw/we-built-a-canva-shaped-editor-into-our-client-workspace-heres-what-we-left-out-on-purpose-2n9o</link>
      <guid>https://dev.to/sharphaw/we-built-a-canva-shaped-editor-into-our-client-workspace-heres-what-we-left-out-on-purpose-2n9o</guid>
      <description>&lt;p&gt;Every agency I know has the same handoff problem with social templates, and it's written up on Reddit. A designer builds the system in Figma. The client wants to edit their own posts, so the designer rebuilds it in Canva, "essentially tracing over a screenshot of that design on a Canva canvas", as one of them put it on r/FigmaDesign. A few weeks later the posts come back with the wrong green and a stretched logo, and the designer is asked why the brand looks off.&lt;/p&gt;

&lt;p&gt;I'm Gabriel, founder of SharpHaw, a small agency in Lisbon, and I built SharpOS, the workspace we run every client engagement in. Studio is the design surface inside it. This is what it does, how it's built, and exactly what we left out, since part 1 of this series already admitted that Studio is not Figma.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Studio treats a template as data with named slots. People fill slots in the canvas editor; agents fill them over the API; the result publishes to Instagram from the same workspace the client reads. Everything Figma and Canva do beyond that, we deliberately didn't rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Figma and Canva are for, and where the handoff breaks
&lt;/h2&gt;

&lt;p&gt;Figma is where a design system gets made: components and constraints, the precision a brand identity needs. Canva is where a non-designer edits a finished layout without breaking it, which is why a designer on r/graphic_design calls it "the most client-accessible / design-friendly templating tool I've found for clients".&lt;/p&gt;

&lt;p&gt;Both are good at their job. The break is between them, and it's structural. The template lives in a tool the client rents, on an account the agency either shares or loses access to. The posts it produces live somewhere else again, in a scheduler or a camera roll. The brand system that should govern the template lives in a PDF. Even where those tools expose automation, nothing in that chain knows a card on the client's content board exists, so nothing can fill the template when the card is approved. A human re-traces it and uploads the result somewhere else. Every week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Studio does instead
&lt;/h2&gt;

&lt;p&gt;Studio is SharpOS's canvas editor for social content. Three objects: &lt;strong&gt;templates&lt;/strong&gt; (org-scoped, reusable layouts sized per platform), &lt;strong&gt;projects&lt;/strong&gt; (containers for a set of posts), and &lt;strong&gt;frames&lt;/strong&gt; (the slides inside a project, each optionally seeded from a template). Members with edit rights design in a three-panel editor; export gives you PNG or JPG, or MP4 when a frame carries video; publish sends a frame to the organisation's connected Instagram account without leaving the hub.&lt;/p&gt;

&lt;p&gt;The part that changes the workflow is the slot. Any text, icon, image or video element in a template can carry a slot name. The API then treats the template as a form: create a frame from a template, pass slots for text and icons and image slots or video slots for media, and Studio writes the layers. That is how covers on our own editorial boards get made — a template called "Dev.to Thumbnail" carries one image slot, and the publishing pipeline fills it. No editor session, no designer.&lt;/p&gt;

&lt;p&gt;Access follows the workspace. Studio sits behind the Studio feature flag, checked server-side on every call. Templates and projects carry &lt;code&gt;client&lt;/code&gt; or &lt;code&gt;internal&lt;/code&gt; visibility; org members read and edit client-visible assets and cannot see internal ones. The client's team member opening the workspace sees the same project the agency is working on, not an export of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The canvas is a real drawing surface, and the entire design of a frame is one document: a tree of text, rectangles, circles, images, video, icons, SVGs and groups. Groups are flex containers, so a caption block reflows when the headline grows. Any element can be marked as a slot ("headline", "cover", "logo"), and a slot is what makes a template fillable: the API fills the slots and leaves the rest of the design alone.&lt;/p&gt;

&lt;p&gt;The decision that cost the most, and the one I'd warn anyone building a canvas editor about: &lt;strong&gt;every visual property exists in four rendering contexts&lt;/strong&gt; — the interactive frame, the read-only preview of the other frames, the headless export, and the thumbnail. Video adds a fifth. Add a corner-smoothing control to the properties panel and forget the export path, and the post looks right in the editor and wrong on Instagram. So every effect has exactly one place where it is resolved, colour opacity goes through one shared step everywhere, and the table that lists those contexts sits near the top of the feature's own documentation.&lt;/p&gt;

&lt;p&gt;Two more decisions came from production rather than planning. A project's frame count used to be computed by reading every frame in the organisation; each frame's design is roughly 20 KB, one org reached 817 frames, and the listing crossed the database's per-request read limit and failed on every call until we stored the count on the project itself. And publish renders now record which renderer produced them, because a server-side fallback rasteriser once produced a drifted carousel cover that passed every freshness check and reached Instagram while the project itself looked correct. We retired that renderer version outright; every frame carrying it reads as stale and re-renders on its next open.&lt;/p&gt;

&lt;p&gt;Video export runs in the browser, encoding H.264 and mixing the audio down locally. A frame that is exactly one full-bleed video publishes the original file with no render at all, which is most Reels.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Studio doesn't do
&lt;/h2&gt;

&lt;p&gt;This is the section I'd want from anyone who built instead of bought.&lt;/p&gt;

&lt;p&gt;Studio is not a design system tool. There are no components with variants, no shared styles, no plugins, no vector pen, no prototyping. If the brand identity is being designed, it's being designed in Figma, and it should be. Studio starts once the system exists.&lt;/p&gt;

&lt;p&gt;It is not Canva either. There is no stock library, no thousands of starter templates, no consumer polish. Our templates are ours, per client, built once. The editor is desktop-only. The four-context rule means every new visual feature costs four implementations, so the property list grows slowly and on purpose.&lt;/p&gt;

&lt;p&gt;And a template made in Studio is data with slots. That is its whole advantage and its whole limit: a machine can fill it, which means a person has to design it to be fillable. A template that can't be filled by a machine is a mockup with a nicer name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the service
&lt;/h2&gt;

&lt;p&gt;Studio is how the &lt;a href="https://sharphaw.com/services/content-engine" rel="noopener noreferrer"&gt;Content Engine&lt;/a&gt; ships visuals: a board card gets approved, a frame gets filled, a post goes out, and the client sees all three steps in &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;their workspace&lt;/a&gt;. The feature page at &lt;a href="https://sharphaw.digital/features/studio" rel="noopener noreferrer"&gt;sharphaw.digital/features/studio&lt;/a&gt; has a recorded walkthrough of the editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  A question for people who've built on Konva
&lt;/h2&gt;

&lt;p&gt;If you've built or maintained a canvas editor, what did you cut first when you realised every property had to exist in every render path? I'm curious whether anyone found a way around the four-times rule rather than a way to enforce it.&lt;/p&gt;

&lt;p&gt;This is part of &lt;code&gt;SharpOS, surface by surface&lt;/code&gt;. Part 1, the tool stack we replaced and the row we couldn't: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>design</category>
      <category>javascript</category>
    </item>
    <item>
      <title>We replaced the brand guidelines PDF with tokens the client's forms, link page and chat widget inherit</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Fri, 04 Sep 2026 14:00:27 +0000</pubDate>
      <link>https://dev.to/sharphaw/we-replaced-the-brand-guidelines-pdf-with-tokens-the-clients-forms-link-page-and-chat-widget-5g6f</link>
      <guid>https://dev.to/sharphaw/we-replaced-the-brand-guidelines-pdf-with-tokens-the-clients-forms-link-page-and-chat-widget-5g6f</guid>
      <description>&lt;p&gt;The brand guidelines PDF has a specific moment of death, and it is the handover. A designer on r/graphic_design asked it plainly: &lt;em&gt;"this is my first time I'm creating a brand guidelines document for my client. I don't know how to handover it to the client."&lt;/em&gt; Whatever the answer was, the file went into a Drive folder, and six months later the client's contact form was one purple, their link-in-bio was another, and the chat widget on the site still ran the vendor's default blue.&lt;/p&gt;

&lt;p&gt;I run SharpHaw, a small agency in Lisbon, and I'm the engineer behind SharpOS, the workspace we run every client engagement in. Brand Kit is the row on our replacement ledger that stands in for the PDF. This is what it does, how it's built, and where it is deliberately smaller than the tools designers already use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; the PDF describes the brand; the kit is what the client's forms, link page and support widget read at runtime. We kept it small on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the PDF is for, and where it breaks
&lt;/h2&gt;

&lt;p&gt;A brand guide is an argument. It says why the mark looks the way it does, what the photography should feel like, which CMYK values stand in for a hex that print can't reproduce. None of that belongs in a database, and I'm not proposing to put it there.&lt;/p&gt;

&lt;p&gt;The problem is narrower. Nothing executes a PDF. A veteran on r/branding put it better than I can: &lt;em&gt;"If your identity isn't embedded in the tools your team actually uses, it will drift. Within six months, your social media will look different from your site."&lt;/em&gt; For an agency the drift is worse, because the touchpoints that go wrong are the ones we ship. A form we built, a link page we built, a chat widget we embedded. If those don't read the brand from somewhere, we are the drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Brand Kit does instead
&lt;/h2&gt;

&lt;p&gt;Every organisation in SharpOS gets one Brand Kit at the Brand page of the hub. It holds a theme scope (light, dark or both), up to six fixed logo slots plus custom named images, semantic colour variables with a light and a dark value each, named type styles with a Google Fonts family, a base radius with a derived sm/md/lg/xl scale, an icon pack, and a list of markdown documents for the parts of the brand that are prose. Every section is optional. An empty kit shows a teaching empty state, and Preview only renders what has content.&lt;/p&gt;

&lt;p&gt;Any org member can read it; owners, managers and editors change it inline in Edit mode, with a debounced autosave. The Brand Kit feature flag is on by default and enforced on the backend as well as the route. It is org configuration rather than content, so it carries no client/internal visibility toggle.&lt;/p&gt;

&lt;p&gt;Two things make it more than a settings page. First, the export dialog copies the tokens as CSS variables (the root variables plus the dark override), JSON, or a Tailwind theme-extension snippet, so a client's own site can consume the same values we do. Second, and this is the part I care about, downstream surfaces inherit the kit as their default theme. Every form, every Spotlight link page and the Support widget resolve the kit's colours, fonts, radius and theme mode through one shared resolver. Change the primary colour once and every form that hasn't overridden it restyles.&lt;/p&gt;

&lt;p&gt;You can open a populated kit in the browser-only showcase, no login: &lt;a href="https://sharphaw.digital/showcase/palazzo-marzano/brand" rel="noopener noreferrer"&gt;Palazzo Marzano's brand kit&lt;/a&gt;. The feature page is at &lt;a href="https://sharphaw.digital/features/brand-kit" rel="noopener noreferrer"&gt;sharphaw.digital/features/brand-kit&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Three decisions carried most of the weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inheritance is a name heuristic, and I say so.&lt;/strong&gt; Colour entries in the kit carry no semantic role. A colour has a name, a light value and optionally a dark value, and nothing more, because forcing a role taxonomy onto a designer's palette produces a form nobody fills in. So the surfaces that inherit the kit map by name: a colour called "primary" or "brand" becomes the accent, "background" or "surface" becomes the background, "foreground" or "text" becomes the text colour. The honest consequence is that renaming a colour can change how a form looks. We documented that rather than hiding it, because the alternative is a schema nobody fills in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theme scope is destructive, on every write path.&lt;/strong&gt; Switching a kit from "both" to light-only clears the dark values instead of hiding them. That rule is enforced on the server on every save, whether the change comes from the hub or the API, so a kit can't be left inconsistent by either. Because it deletes data, narrowing the scope opens a confirm dialog that names exactly what goes ("the dark value from 4 colours, and the logo — alternative image"), computed from the current draft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The kit never auto-renders a logo.&lt;/strong&gt; A logo slot can point at a Media Center asset or at a Studio frame. A frame-backed slot stores a reference, so editing the frame in Studio updates the brand asset, but the image shown is the frame's last published render, and that render can be older than the frame. Server-side rendering has known font and wrap drift against the editor. Silently healing a brand asset with a slightly wrong render would be worse than an honest badge, because the thing being altered is the client's identity. So every slot reports whether its render is ready, stale, missing or a video, and a stale one shows an amber "Out of date" with an "Open in Studio" link. The share image only uses a frame-backed image while its render is fresh, because social platforms cache a share image hard.&lt;/p&gt;

&lt;p&gt;Adding frame-backed slots needed no migration: a slot value is either an asset or a frame reference, the two are told apart by shape, and every pre-existing kit kept working unchanged.&lt;/p&gt;

&lt;p&gt;The export is deliberately boring. A kit in "both" scope with a colour, a radius and a heading style exports as a flat set of CSS variables: the colour, the radius and its derived small, medium, large and extra-large steps, the corner smoothing, the heading font and size, plus a dark override that only carries the values that differ. The dark override appears only in "both" scope, and only when at least one colour has a dark value. A light-only or dark-only kit exports a single flat set, because there is nothing to switch between.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;Brand Kit is smaller than a design-system tool, and it should stay that way. There are no components, no Figma or Storybook sync, no versioned releases of the system. It is not an asset library either; Media Center is, and the kit only references it. The brand documents are a markdown textarea with a preview tab, not the collaborative block editor the rest of SharpOS uses. Studio and Pages don't consume the tokens yet. The squircle preview relies on the CSS corner shape property, which only recent browsers render, though the smoothing value always exports as a corner smoothing token for whatever tool reads it next.&lt;/p&gt;

&lt;p&gt;If you run a design team with a component library and a governance process, keep it. Brand Kit is for the business that has one brand, six surfaces we run for it, and nobody whose job is to keep them aligned. The kit is the alignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the service
&lt;/h2&gt;

&lt;p&gt;We use it inside &lt;a href="https://sharphaw.com/services/conversion-first-websites" rel="noopener noreferrer"&gt;Conversion-First Websites&lt;/a&gt;: the site's tokens live in the kit, the forms on the site inherit them, and the client can read the same kit in &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;SharpOS&lt;/a&gt; at any hour. That is the whole pitch, and it fits in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I have for you
&lt;/h2&gt;

&lt;p&gt;Where does your brand actually live today, and what reads it? If the honest answer is "a PDF, and people", I'd like to hear which touchpoint drifted first.&lt;/p&gt;

&lt;p&gt;This is part of the series &lt;code&gt;SharpOS, surface by surface&lt;/code&gt;. Part 1 is the ledger itself: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;We replaced a 12-tool client stack with one workspace we built&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>design</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A production checklist for Framer and Webflow templates</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Fri, 04 Sep 2026 14:00:25 +0000</pubDate>
      <link>https://dev.to/sharphaw/a-production-checklist-for-framer-and-webflow-templates-2po8</link>
      <guid>https://dev.to/sharphaw/a-production-checklist-for-framer-and-webflow-templates-2po8</guid>
      <description>&lt;p&gt;I do not reject website templates. I reject ambiguous production ownership.&lt;/p&gt;

&lt;p&gt;A good Framer or Webflow template can remove a large amount of layout work. It may already have responsive sections, a CMS structure, interactions and a coherent visual system. That is useful engineering leverage.&lt;/p&gt;

&lt;p&gt;It is not a production contract.&lt;/p&gt;

&lt;p&gt;Before I call a template-based marketing site ready, I want six things defined and tested. They are the difference between "the pages render" and "the website can do its job without a developer watching it".&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Give every page a conversion contract
&lt;/h2&gt;

&lt;p&gt;Start with one object per important page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ConversionContract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;primaryAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit_form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;book_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start_checkout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;visit_pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="na"&gt;successDestination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contactPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConversionContract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/contact&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;owner-operator comparing website partners&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;leave with a clear recommendation for the site&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;primaryAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;book_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;successDestination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;calendar confirmation and CRM lead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;growth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is deliberately boring. It stops a page from collecting three competing CTAs because the template shipped with buttons in three places.&lt;/p&gt;

&lt;p&gt;The contract also gives QA something testable. If the primary action is &lt;code&gt;book_call&lt;/code&gt;, a click on a social icon is not evidence that the page works.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Replace the content model, not only the nouns
&lt;/h2&gt;

&lt;p&gt;Swapping a template's company name, colours and screenshots is the easy pass. The deeper pass asks whether the section order matches the buyer's decision.&lt;/p&gt;

&lt;p&gt;For each section, write down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the question it answers&lt;/li&gt;
&lt;li&gt;the evidence it uses&lt;/li&gt;
&lt;li&gt;the next objection it creates&lt;/li&gt;
&lt;li&gt;whether the next section pays that objection off
Delete sections that have no job. Add the missing decision before worrying about visual variety.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A template is a useful hypothesis about how a generic buyer might read. Your finished site needs a specific argument for your buyer.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Name events before adding analytics
&lt;/h2&gt;

&lt;p&gt;"Install analytics" is not an acceptance criterion. Define the event vocabulary first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;MarketingEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cta_clicked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form_started&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form_submitted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;booking_confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;bookingId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify that each event fires once, carries the expected properties and survives consent rules, redirects and client-side navigation.&lt;/p&gt;

&lt;p&gt;Both platforms can support serious optimisation. Framer has built-in experiments around defined conversion events, and Webflow publishes a workflow built around blockers, hypotheses, QA and a testing roadmap. The tools exist. The implementation still needs a measurement model and an owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Test the handoff after submit
&lt;/h2&gt;

&lt;p&gt;The successful form state is the middle of the flow, not the end.&lt;/p&gt;

&lt;p&gt;My minimum test matrix includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;valid submission -&amp;gt; CRM record exists -&amp;gt; owner notified -&amp;gt; confirmation shown duplicate email -&amp;gt; defined merge or duplicate behaviour provider timeout -&amp;gt; recoverable error -&amp;gt; no silent loss spam submission -&amp;gt; filtered -&amp;gt; conversion reporting protected mobile submit -&amp;gt; keyboard closes -&amp;gt; state remains visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the tests in production after launch as well as in preview. A polished form that posts to the wrong destination is a conversion bug, even if every pixel is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Write the ownership and rollback note
&lt;/h2&gt;

&lt;p&gt;Before handoff, record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who owns the domain and platform account&lt;/li&gt;
&lt;li&gt;who can publish&lt;/li&gt;
&lt;li&gt;where form data goes&lt;/li&gt;
&lt;li&gt;where analytics lives&lt;/li&gt;
&lt;li&gt;which third-party components or licences the site uses&lt;/li&gt;
&lt;li&gt;how to restore the last working version&lt;/li&gt;
&lt;li&gt;who receives the first alert when something fails
Webflow's paid marketplace templates use a single-use licence, and template-specific support belongs to the creator unless the problem is in Webflow itself. Framer's own template checklist says its recommendations are optional and templates are not manually reviewed. Those are manageable constraints. They simply belong in the operating record.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Schedule the first post-launch change
&lt;/h2&gt;

&lt;p&gt;Do not end the project plan with "launch".&lt;/p&gt;

&lt;p&gt;End it with the first evidence review. Pick a date, name the decision-maker and state what will be inspected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FirstReview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;traffic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cta_rate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form_quality&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sales_objections&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="na"&gt;decisionOwner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;allowedOutcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;instrumentation_fix&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Low traffic may make an A/B test inconclusive. That does not remove the need to review the site. It changes the evidence you use. Form quality, sales-call objections, support questions and failed handoffs can still produce useful work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The template is not the shortcut you should fear
&lt;/h2&gt;

&lt;p&gt;The dangerous shortcut is leaving the decisions unnamed.&lt;/p&gt;

&lt;p&gt;Use a template when it fits. Modify it until the content model belongs to the business. Define the conversion contract. Instrument the actions. Test the handoff. Record ownership. Put the first review on the calendar.&lt;/p&gt;

&lt;p&gt;At that point the template has done its job: it saved implementation time without becoming the operating model.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.framer.com/help/articles/how-to-run-an-a-b-test-on-your-framer-site/" rel="noopener noreferrer"&gt;Framer A/B testing&lt;/a&gt;, &lt;a href="https://www.framer.com/help/articles/template-best-practices/" rel="noopener noreferrer"&gt;Framer template practices&lt;/a&gt;, &lt;a href="https://help.webflow.com/hc/en-us/articles/33961270477971-Webflow-templates-overview" rel="noopener noreferrer"&gt;Webflow template overview&lt;/a&gt;, &lt;a href="https://webflow.com/templates/template-licenses" rel="noopener noreferrer"&gt;Webflow template licence&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webflow</category>
      <category>frontend</category>
      <category>ux</category>
    </item>
    <item>
      <title>How every file in our client workspace lands in one library (and where Drive still wins)</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Fri, 04 Sep 2026 08:00:25 +0000</pubDate>
      <link>https://dev.to/sharphaw/how-every-file-in-our-client-workspace-lands-in-one-library-and-where-drive-still-wins-ed8</link>
      <guid>https://dev.to/sharphaw/how-every-file-in-our-client-workspace-lands-in-one-library-and-where-drive-still-wins-ed8</guid>
      <description>&lt;p&gt;Every client engagement starts with the same email. "Could you send us your logo, a few photos and the brand PDF?" What comes back is a Drive link, a Dropbox request, two WhatsApp images and a PowerPoint. Three months later the site's hero image lives in Drive, the Instagram crop lives in Dropbox, and the approved logo is in a chat thread nobody can search.&lt;/p&gt;

&lt;p&gt;I run SharpHaw, a small agency in Lisbon, and I built SharpOS, the workspace we run every client in. This series takes our replacement ledger one row at a time. This row: Google Drive and Dropbox, and the surface that stands in for them, Media Center.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; a shared folder stores files. A client workspace needs a library that every other surface writes into and reads from, so an image uploaded anywhere is the same asset everywhere. That is the whole design, and it has real limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a shared drive is for, and where it strains
&lt;/h2&gt;

&lt;p&gt;Drive and Dropbox are good at the job they were built for: a folder tree, sync to a laptop, a share link, permissions per person. For a company's own documents that is exactly right, and I'm not going to pretend an agency workspace does any of that better.&lt;/p&gt;

&lt;p&gt;Where it strains is the client relationship, and the r/agency threads on this are blunt. &lt;em&gt;"We frequently run into issues with clients not having Gmail accounts or Gmail connected work emails and having them set that up is annoying."&lt;/em&gt; Another agency creates a folder per client, shares it, then asks the subreddit how to take it back when the engagement ends. The failure is structural. The folder belongs to someone's account, the link belongs to whoever was emailed it, and the files end up wherever the last tool needed them. Nothing about a folder knows that the file inside it is the cover of a page, the logo in a brand kit, or the image a form respondent uploaded ten minutes ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Media Center does instead
&lt;/h2&gt;

&lt;p&gt;Media Center is the organisation's shared file library: images, documents, video and other approved files, uploaded once, organised into nested folders, searched, and reused across the product. Every org member can browse it. Uploads, moves, renames and deletion sit behind the asset-edit right, checked at the route and again in the client. The Media Center feature flag gates the route and the save and delete mutations server-side, like every other surface in the workspace.&lt;/p&gt;

&lt;p&gt;The part that matters is what else writes into it. A cover set on a page or a board card, an image property on a card, a media field on a public form, an image pushed over the API: all of them register in the same library. Form uploads land in an auto-created a Forms folder named after the form folder keyed to the form id, so renaming the form doesn't orphan the folder. REST callers get folder and asset CRUD, moves and both upload paths, through the same Media Center flag and the same capability the hub checks. There is one asset pool per organisation, and every surface is a door into it.&lt;/p&gt;

&lt;p&gt;You can open one and click around: &lt;a href="https://sharphaw.digital/showcase/fernweh/media" rel="noopener noreferrer"&gt;the Media Center inside a showcase workspace&lt;/a&gt;. It runs on fixtures in your browser, no account, nothing persisted.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Storage follows the same pattern everywhere in SharpOS: the browser asks for an upload slot, sends the bytes straight to storage, then records the asset — its name, type, size, folder, tags and what kind of preview it gets — as a small record that points at the stored file rather than containing it. Two decisions I'd defend in a review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation reads storage, never the caller.&lt;/strong&gt; File types are filtered before upload for the user's benefit, then enforced again when the asset record is created, from what storage itself reports about the file, never from a type or size the client claimed. That one backstop is shared by the hub, by form uploads from strangers, and by the API, so there is no path where a caller's claim about a file is trusted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-step uploads because of a proxy cap.&lt;/strong&gt; Production API traffic goes through a proxy that caps request bodies at 20 MB, while an organisation's upload limit reaches 250 MB. So the API mirrors the hub's flow: ask for an upload slot, send the bytes directly, then register the asset. A one-call route that takes a public URL or an inline payload exists for anything under the cap. Deleting a folder over the API requires an explicit "recursive" flag when the folder isn't empty; the hub can afford a confirm dialog, an API caller cannot.&lt;/p&gt;

&lt;p&gt;Browsing is paged at 48 records, and a page never resolves preview URLs up front. Previews are requested separately for the files a new page added, deduplicated, and refused above 60 at a time. The upload bar reports real bytes sent rather than an animation, because I wanted the percentage to mean something.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;Media Center is shallower than Drive or Dropbox, on purpose, and you should know where.&lt;/p&gt;

&lt;p&gt;There is no desktop sync client and no offline copy. There is no version history on a file; you upload the new one. There is no share-with-an-outsider feature: access is membership, a public form's respondent can upload in, but nobody outside the organisation browses the library. There is no document editing in place; the collaborative writing surface in SharpOS is Pages, and Media Center is where Pages puts the images. Deleting an asset that a page or card still uses as its cover leaves a stale reference the cover UI treats as empty, rather than blocking the delete.&lt;/p&gt;

&lt;p&gt;If the job is the client's own company files, keep the drive. If the job is the files the engagement produces and consumes, the library is where they should already be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the service
&lt;/h2&gt;

&lt;p&gt;Every SharpHaw subscription runs inside SharpOS, and Media Center is the asset pool under the &lt;a href="https://sharphaw.com/services/content-engine" rel="noopener noreferrer"&gt;Content Engine&lt;/a&gt;: the photos, exports and brand files a week's content draws on sit next to the pages and boards that use them. The &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;SharpOS overview&lt;/a&gt; shows the rest of the workspace, and &lt;a href="https://sharphaw.digital/features/media-center" rel="noopener noreferrer"&gt;the feature page&lt;/a&gt; has the walkthrough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I'd ask you
&lt;/h2&gt;

&lt;p&gt;If you've moved a team off a shared drive into something purpose-built, what did people quietly keep using the drive for? That list is usually the honest spec for what the replacement still lacks.&lt;/p&gt;

&lt;p&gt;This is part of &lt;code&gt;SharpOS, surface by surface&lt;/code&gt;. Part 1 covers the whole ledger and the row we couldn't replace: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>backend</category>
      <category>storage</category>
    </item>
    <item>
      <title>How I made AI disclosure part of the chatbot shell</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Thu, 03 Sep 2026 14:00:28 +0000</pubDate>
      <link>https://dev.to/sharphaw/how-i-made-ai-disclosure-part-of-the-chatbot-shell-4e87</link>
      <guid>https://dev.to/sharphaw/how-i-made-ai-disclosure-part-of-the-chatbot-shell-4e87</guid>
      <description>&lt;p&gt;If your chatbot’s AI disclosure lives in the welcome message, an ordinary copy edit can remove it.&lt;/p&gt;

&lt;p&gt;That was the failure mode I wanted to eliminate in SharpOS Support. The result is simple: the product shell always renders an identity line before the conversation starts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI assistant · May make mistakes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The organisation can configure the assistant name, welcome copy, knowledge and fallback contact. It cannot disable that line.&lt;/p&gt;

&lt;p&gt;This article is about the implementation model, not legal advice. Article 50 of the EU AI Act has applied since 2 August 2026, and the European Commission says providers of systems designed for direct two-way interaction should ensure that people are informed they are interacting with AI from the start of the first interaction.&lt;/p&gt;

&lt;p&gt;Here is the architecture I use around that requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Render identity outside generated content
&lt;/h2&gt;

&lt;p&gt;The assistant should not be responsible for disclosing that it is an assistant.&lt;/p&gt;

&lt;p&gt;Generated content can drift. Instructions change. Welcome messages are localised. A retrieval failure can produce a response before the intended introduction. The disclosure belongs in deterministic UI.&lt;/p&gt;

&lt;p&gt;Conceptually, the shell owns it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AssistantIdentity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"AI assistant. May make mistakes."&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/ai-transparency"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;AI assistant&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; · &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;May make mistakes.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real implementation details will differ. The important boundaries are stable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the text appears before the first response;&lt;/li&gt;
&lt;li&gt;it is not supplied by the model;&lt;/li&gt;
&lt;li&gt;organisation-level configuration cannot clear it;&lt;/li&gt;
&lt;li&gt;the link opens a fuller transparency explanation;&lt;/li&gt;
&lt;li&gt;the accessible name still makes sense without the visual separator.
Do not rely on a bot avatar or a clever product name to make AI “obvious”. Explicit text is cheaper to test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Treat the knowledge base as an allow-list
&lt;/h2&gt;

&lt;p&gt;The disclosure says what the system is. It does not constrain what the system says.&lt;/p&gt;

&lt;p&gt;SharpOS Support grounds answers in approved knowledge. I think of that knowledge as an allow-list of claims rather than a folder of helpful documents.&lt;/p&gt;

&lt;p&gt;A source should have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;KnowledgeSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;retired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;reviewedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only approved, current sources should enter retrieval. A retired pricing page should not remain available because embeddings were generated once and forgotten.&lt;/p&gt;

&lt;p&gt;The model instruction is equally important: answer from the supplied evidence, identify missing evidence and avoid filling the gap with general knowledge when the question is about this business.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make uncertainty a typed product state
&lt;/h2&gt;

&lt;p&gt;Do not model fallback as another friendly paragraph the model might choose to write.&lt;/p&gt;

&lt;p&gt;The orchestration layer should be able to produce a result that is not an answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SupportResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;sourceIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fallback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;missing_knowledge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low_confidence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blocked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sensitive_request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI then renders organisation-approved fallback copy and a real contact route.&lt;/p&gt;

&lt;p&gt;In SharpOS Support, this is intentionally not a fake live-agent handoff. If the business provides an email, WhatsApp number or phone route, the fallback points there. If no person is waiting in a support queue, the interface should not imply that one is.&lt;/p&gt;

&lt;p&gt;Typed states also make the behaviour measurable. You can count fallbacks without attempting to infer them later from prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Store enough context for review, not everything forever
&lt;/h2&gt;

&lt;p&gt;Conversation review is part of the control loop.&lt;/p&gt;

&lt;p&gt;Store the outcome type, source IDs, timestamps and the minimum conversation content needed for the business to diagnose gaps. Apply access controls and a retention policy appropriate to the personal data customers may put into a chat box.&lt;/p&gt;

&lt;p&gt;The review queue should answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which questions repeatedly fell back?&lt;/li&gt;
&lt;li&gt;Which approved source produced the answer?&lt;/li&gt;
&lt;li&gt;Which visitor suggestions recur?&lt;/li&gt;
&lt;li&gt;Did the contact exit work?&lt;/li&gt;
&lt;li&gt;Which knowledge source needs an owner or review date?
This is more actionable than a total-message chart.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Test the shell, not only the model
&lt;/h2&gt;

&lt;p&gt;Model evaluations will not catch a disclosure hidden below a mobile viewport.&lt;/p&gt;

&lt;p&gt;I add deterministic product tests around the interaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shows AI identity before the first message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/support&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AI assistant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;May make mistakes.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-message]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then test the whole path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open as a first-time visitor.&lt;/li&gt;
&lt;li&gt;Check the disclosure on desktop, mobile and zoomed layouts.&lt;/li&gt;
&lt;li&gt;Ask an answerable question and verify the source boundary.&lt;/li&gt;
&lt;li&gt;Ask an unsupported question and verify the typed fallback.&lt;/li&gt;
&lt;li&gt;Follow the contact route.&lt;/li&gt;
&lt;li&gt;Confirm the conversation appears in the review surface.
## The design rule&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Product transparency should be deterministic. Model behaviour should be bounded. Failure should be explicit. Review should produce a change.&lt;/p&gt;

&lt;p&gt;That is the difference between adding disclaimer copy and making disclosure part of the system.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>ux</category>
      <category>privacy</category>
    </item>
    <item>
      <title>We didn't replace Google Analytics. We put a page in front of it that says what needs attention.</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Thu, 03 Sep 2026 14:00:26 +0000</pubDate>
      <link>https://dev.to/sharphaw/we-didnt-replace-google-analytics-we-put-a-page-in-front-of-it-that-says-what-needs-attention-k9g</link>
      <guid>https://dev.to/sharphaw/we-didnt-replace-google-analytics-we-put-a-page-in-front-of-it-that-says-what-needs-attention-k9g</guid>
      <description>&lt;p&gt;Every agency I know has built the client a dashboard at least once. A Looker Studio page with the GA4 connector, a logo in the corner, twelve tiles, a date picker. The client opens it twice. The first time they say it looks great. The second time they email to ask what it means, and from then on the monthly update goes back to being a call.&lt;/p&gt;

&lt;p&gt;I run SharpHaw, a small agency in Lisbon, and SharpOS is the workspace we run every client engagement in. Analytics is the surface I have rewritten the most, because the first version was exactly that dashboard. This post is what it became, what it deliberately doesn't do, and why it sits on top of PostHog instead of trying to be one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; SharpOS Analytics is a reading surface, not a collector. PostHog underneath, a thresholded "Needs attention" list on top, and every signal is one sentence with a link to the surface that fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GA4 and Looker are for, and where they strain
&lt;/h2&gt;

&lt;p&gt;Google Analytics 4 is a collector with a reporting UI attached. It is very good at the first job, and the people on r/GoogleAnalytics have been saying the second part out loud for a while: &lt;em&gt;"They have shifted from an analytics tool to a data collection tool with GA4. It is an amazing shift for power users, but does not seem to sit well with regular users."&lt;/em&gt; Looker, and Looker Studio with it, is a builder: hand it sources and it will let you compose any chart you can imagine. Both are excellent tools for someone whose job is analytics.&lt;/p&gt;

&lt;p&gt;The founder paying an agency is not that person. Their question is narrower and harder: what moved this month, is anything wrong, and what did you do about it. A blank canvas answers none of that, so someone at the agency fills the canvas with tiles, and the result is what our audience research keeps quoting back at us: &lt;em&gt;"reports full of jargon, green arrows, and charts that don't really mean anything."&lt;/em&gt; One Hacker News commenter called it the "any data is good data" trap — big, beautiful dashboards full of irrelevant, misleading data. I have built that dashboard. It ships fast and it gets read never.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Analytics does instead
&lt;/h2&gt;

&lt;p&gt;One page, four tabs, one shared date range that lives in the URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overview&lt;/strong&gt; is the tab a founder opens. It carries a row of cross-source signals (visitors, Spotlight clicks, support conversations, satisfaction, uptime), one "Audience over time" chart with all three sources on a shared axis, and &lt;strong&gt;Needs attention&lt;/strong&gt;: a deterministic, thresholded list where each row is one checkable sentence and a link to the surface that resolves it. The thresholds are code, covered by tests, rather than a judgement call made per client. A status page that isn't configured shows up there as a sentence, because an absent tile is indistinguishable from a healthy one that scrolled off. When nothing crosses a threshold, the list says "Nothing needs attention this period", which is an answer in itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Website&lt;/strong&gt; is the organisation's own PostHog project, read in depth: five KPI cards with deltas against the previous equal-length period, visitors over time with the last period as a dashed line, traffic sources, top pages, devices, countries, entry and exit pages, UTM sources, week-one retention, a goal-conversion readout and a live "right now" tile. SharpOS installs no tracking and warehouses nothing for this tab. A platform admin connects the client's existing PostHog project once, the credentials stay server-side, and members read the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spotlight&lt;/strong&gt; and &lt;strong&gt;Support&lt;/strong&gt; report on data SharpOS collects itself: link-page views, clicks and CTR by source, country and device; conversations by outcome, resolution and fallback rates, reply speed, unanswered questions with a link to triage them, and the assistant's token spend priced with the inbox's own table.&lt;/p&gt;

&lt;p&gt;Roles do the rest. Any readable member sees Overview and Website; the two agency-internal tabs are editor and above; the whole surface sits behind the Analytics feature flag, and Spotlight and Support additionally need their own. You can open a fixture version of it here: &lt;a href="https://sharphaw.digital/showcase/northlight/analytics" rel="noopener noreferrer"&gt;Analytics in the SharpOS showcase&lt;/a&gt; — browser-only, no account, nothing you type leaves the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Three decisions carry the surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each tab loads only its own source.&lt;/strong&gt; Website, Spotlight and Support are separate tabs with separate loading states, and opening one never fetches the others. That matters because the Website tab's PostHog request is metered upstream: someone who opened Analytics to read the Support inbox should not spend a website query. Once a tab has loaded, its data stays warm while you move between tabs, and the website dashboard is cached per date window, so switching back is instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spotlight and Support numbers come from daily rollups.&lt;/strong&gt; Both tabs read pre-aggregated daily rows rather than scanning the underlying conversations or click events, because no capped scan answers a ninety-day question honestly, and the busiest organisations are exactly the ones a scan would under-report. The counters are updated by the same operations that record each event, and if a timestamp is unusable the counter is skipped rather than the event being lost. A reporting number is never allowed to be the reason a visitor's message fails to save.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hidden tab is not enforcement.&lt;/strong&gt; The tab strip is derived from what your role and your organisation's flags allow, but every tab re-runs its own check when opened, and every query re-asserts the flag and the permission on the server. A direct URL is never looser than the strip.&lt;/p&gt;

&lt;p&gt;One more, because it is the kind of thing that bites later: the conversion event a client chooses is the only piece of caller-supplied text that reaches the website analytics query, so it is checked against a strict allow-list of characters and refused if it doesn't match. It is never quietly "cleaned" into a different name. A goal either matches exactly or the query doesn't run.&lt;/p&gt;

&lt;p&gt;There is also a pin control on every KPI card. A member can pin up to six metrics to the hub home, and the pinned tile and the card it came from read one definition — because the first version had two, and home was quietly drawing a clicks line under a CTR label.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;It is not a BI tool. There is no report builder, no data blending, no SQL explorer, no custom dimensions. If the client's team has an analyst who lives in Looker, they should keep Looker. Analytics is read-only, and the Website tab is only as good as the PostHog project behind it. Anything beyond the core visitor and session metrics is best-effort: a failed optional query hides its own block rather than the page, which is the honest way to say "we couldn't get that this time".&lt;/p&gt;

&lt;p&gt;Historical Support numbers rebuilt from the raw tables are approximations; everything written since the rollups shipped is exact. And a viewer sees two tabs rather than four, by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the service
&lt;/h2&gt;

&lt;p&gt;Analytics is where the &lt;a href="https://sharphaw.com/services/ads-management" rel="noopener noreferrer"&gt;Ads Management&lt;/a&gt; and &lt;a href="https://sharphaw.com/services/conversion-first-websites" rel="noopener noreferrer"&gt;Conversion-First Websites&lt;/a&gt; work gets read: the board that moved a card this week is one click from the number the card was meant to move. It ships inside every &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;SharpOS&lt;/a&gt; workspace, and the feature page is at &lt;a href="https://sharphaw.digital/features/analytics" rel="noopener noreferrer"&gt;sharphaw.digital/features/analytics&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I keep coming back to
&lt;/h2&gt;

&lt;p&gt;If you report to clients, what is the one number they actually ask about, and does your dashboard lead with it or bury it under eleven others?&lt;/p&gt;

&lt;p&gt;Part of the series &lt;code&gt;SharpOS, surface by surface&lt;/code&gt;. Part 1, the replacement ledger and the row we couldn't replace: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;We replaced a 12-tool client stack with one workspace we built&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>analytics</category>
      <category>startup</category>
    </item>
    <item>
      <title>We replaced the audit PDF with a work queue. Lighthouse stayed.</title>
      <dc:creator>SharpHaw</dc:creator>
      <pubDate>Thu, 03 Sep 2026 08:00:26 +0000</pubDate>
      <link>https://dev.to/sharphaw/we-replaced-the-audit-pdf-with-a-work-queue-lighthouse-stayed-1geo</link>
      <guid>https://dev.to/sharphaw/we-replaced-the-audit-pdf-with-a-work-queue-lighthouse-stayed-1geo</guid>
      <description>&lt;p&gt;Every agency client has received one: the audit. Forty pages, a score on the cover, a table of issues sorted by severity, exported from a crawler on a Tuesday and emailed with a line like "let's walk through this on the call". Three weeks later somebody asks whether the duplicate title tags were fixed, and the honest answer is that nobody opened the PDF again after the call.&lt;/p&gt;

&lt;p&gt;I run SharpHaw, a small agency in Lisbon, and I built SharpOS, the workspace our client engagements run in. Audits is the row on our replacement ledger that sits opposite Semrush and Lighthouse. This post is about what that row does, what it deliberately doesn't do, and why the audit surface in our own product has no "Run audit" button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; the crawler was never the bottleneck. The PDF was. Audits in SharpOS is where findings live, get rechecked against the live site, and get closed. It does not crawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Semrush and Lighthouse are for, and where they strain
&lt;/h2&gt;

&lt;p&gt;A crawler-backed SEO suite does one thing well: it walks a site and produces findings. Missing descriptions, thin pages, broken canonicals, duplicated titles, a health score you can track over time. Lighthouse does the other half: a lab run against one URL that tells you where the performance, accessibility and SEO basics stand, with the specific audit that failed. Both are good tools — I still run PageSpeed against pages I'm about to ship.&lt;/p&gt;

&lt;p&gt;The strain shows the moment the findings have to reach a client. The crawler lives in the agency's account, so the output has to be exported, and an export is a snapshot. Then the snapshot has to be explained, so it becomes a call. Then the call ends and the findings have nowhere to sit except inbox history. The threads on r/SEO say it plainly: one owner pushing back on an 18-hour audit because &lt;em&gt;"the report is repetitive"&lt;/em&gt;, another warning that autogenerated audits &lt;em&gt;"frequently miss"&lt;/em&gt; what matters. Both are complaints about the document rather than the crawl. Our own playbook has the same line about the agency model in general: audits and strategy documents become PDFs that vanish after the first week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Audits does instead
&lt;/h2&gt;

&lt;p&gt;Audits is the workspace where a site's findings live. Each site a client cares about is an audit target inside their organisation. A run against that target holds the crawled pages, the findings (scored across eleven categories), the artefacts, and a short structured verdict, and the org's members open it the way they open a board. If you want to see what that looks like without an account, the fixture-only showcase runs the real components in your browser: &lt;a href="https://sharphaw.digital/showcase/ironhouse/audits" rel="noopener noreferrer"&gt;open a target here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The part that changes the client's experience is what happens to a finding after it exists. A finding has a status, so it can be set to done. A finding can become a page or a board card in one action, which is how "fix the duplicate titles on the service pages" turns into a card on the same weekly board the client already watches. And a finding can be rechecked against the live site: a recheck that passes deletes the finding, a recheck that fails keeps it and says so. The audit stops being a document you re-read and becomes a queue you drain. The feature page puts it in five words: &lt;a href="https://sharphaw.digital/features/audits" rel="noopener noreferrer"&gt;the audit that doesn't disappear&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Visibility and roles apply the way they do everywhere else in the workspace. A target is &lt;code&gt;client&lt;/code&gt; or &lt;code&gt;internal&lt;/code&gt;; internal ones are platform-admin only and don't appear in listings at all. The Audits feature flag gates the routes, the navigation and every backend access path, so an org without the feature never reaches a handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SharpOS doesn't crawl.&lt;/strong&gt; That is the decision the surface is built around, and it's why there is no "Run audit" button. Crawling and analysis happen in an agent outside the product (ours runs from a repo-local skill with a set of deep-dive lanes: conversion path, message quality, answer-engine readiness, performance, accessibility, trust), and the agent pushes results in through the integration API: create the target, open a run marked as agent-sourced, push the pages, push the findings, push the insights, finalise. Scoring is computed on the server at finalise, from the evidence that was ingested. Every write requires the platform admin key; an organisation's own API token can read client-visible findings and cannot create a single one.&lt;/p&gt;

&lt;p&gt;That split keeps two things honest. A long crawl never has to fit inside a backend function's runtime. And the workspace is the source of truth for what a site looked like, rather than a mirror of whatever the crawler's account remembers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingest reports what was persisted, not what was sent.&lt;/strong&gt; A page-level finding whose URL matches no ingested page is dropped, and the dropped URLs are listed back to the agent, so a push that only partly landed is visible immediately. Ingested pages must belong to the target's host. And a new run replaces the previous run's evidence: pages, findings, artefacts and blobs go, and only the older run's score and finding count stay as history. Storage doesn't grow without bound, and the workspace always shows the current state of the site rather than a stack of stale ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lighthouse stayed, in one specific place.&lt;/strong&gt; Rechecking a Largest Contentful Paint, Cumulative Layout Shift or Interaction to Next Paint finding triggers a fresh PageSpeed probe and evaluates the result against the Core Web Vitals thresholds. Pass, and the finding is deleted. Fail, and it stays, marked as rechecked and failed. If the probe can't be captured at all, the finding is kept — a recheck may only delete evidence when it has evidence. Only finding types SharpOS can genuinely re-evaluate on the live site can be rechecked; anything else is refused up front instead of being silently resolved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;Audits does not out-crawl a dedicated SEO suite, and it isn't trying to. There is no keyword research, no backlink index, no rank tracking, no competitor visibility graph. If your work depends on those, keep the suite. Lighthouse itself isn't replaced either; it's called during rechecks, and PageSpeed remains the lab measurement we trust for that.&lt;/p&gt;

&lt;p&gt;There is also no crawl cockpit. You can't start a run, watch a progress bar, or stop one from the hub, because the run isn't happening there. An hourly sweep marks runs that stopped reporting progress as failed, and the workspace's History tab is where a failed run gets reviewed. For a team that wants a button and a spinner, this is the wrong shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it sits in the SharpHaw service
&lt;/h2&gt;

&lt;p&gt;Audits is the first thing that happens on a &lt;a href="https://sharphaw.com/services/conversion-first-websites" rel="noopener noreferrer"&gt;conversion-first website&lt;/a&gt; engagement and the thing that keeps happening after: the run finds the problems, the board carries them to done, the next run confirms the fix. It ships inside &lt;a href="https://sharphaw.com/sharp-os" rel="noopener noreferrer"&gt;SharpOS&lt;/a&gt; with every subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;The audit-as-PDF shape has survived a long time, and I suspect it's because a document feels like something you handed over, while a queue feels like an admission that the work isn't finished. If you've moved audit findings into a tracked system (tickets, cards, anything with a status), what happened to the findings nobody would have fixed from the PDF? And what did you lose by dropping the document?&lt;/p&gt;

&lt;p&gt;This is part of &lt;code&gt;SharpOS, surface by surface&lt;/code&gt;. Part 1, with the full replacement ledger: &lt;a href="https://dev.to/sharphaw/we-replaced-a-12-tool-client-stack-with-one-workspace-we-built-heres-the-row-we-couldnt-pg3"&gt;We replaced a 12-tool client stack with one workspace we built&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
