<?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: Sushil Barwal</title>
    <description>The latest articles on DEV Community by Sushil Barwal (@dzimark).</description>
    <link>https://dev.to/dzimark</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%2F4195471%2F0da975a7-79f7-48c0-9aa6-5f98f94cdaea.png</url>
      <title>DEV Community: Sushil Barwal</title>
      <link>https://dev.to/dzimark</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dzimark"/>
    <language>en</language>
    <item>
      <title>Human Handoff for Website AI Chatbots: A Spec You Can Actually Build From</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Wed, 11 Nov 2026 07:38:37 +0000</pubDate>
      <link>https://dev.to/dzimark/human-handoff-for-website-ai-chatbots-a-spec-you-can-actually-build-from-oej</link>
      <guid>https://dev.to/dzimark/human-handoff-for-website-ai-chatbots-a-spec-you-can-actually-build-from-oej</guid>
      <description>&lt;p&gt;Most website chatbot projects spend weeks on the model, the prompt and the knowledge base, then bolt on "talk to a human" in the last sprint. That's backwards. For a business site, the handoff is where leads are won or lost: the visitor who asks something the bot can't answer is usually the one with a real, specific need.&lt;/p&gt;

&lt;p&gt;This is the handoff spec we start from when we build a chatbot for a client's website. It's stack-agnostic: it works whether the bot runs on OpenAI, Claude or Gemini, and whether the human side is Intercom, Zendesk, Slack, email or a plain CRM task.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Decide what triggers a handoff
&lt;/h2&gt;

&lt;p&gt;Write the triggers down before you write any code. We use five:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The visitor asks for a person.&lt;/strong&gt; Phrases like "speak to someone", "human", "agent", "call me". Detect these with a simple classifier or a keyword list; don't leave it to the main model's mood.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low confidence.&lt;/strong&gt; The retrieval step found nothing relevant above your threshold, or the model says it doesn't know. Log the score so you can tune the threshold later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restricted topics.&lt;/strong&gt; Refunds over a set amount, complaints, legal questions, medical or financial advice, anything about a specific account. These go straight to a person, with no attempt to answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buying signals.&lt;/strong&gt; A quote request, a booking, a "how soon can you start?". The bot can collect details, but a person should follow up quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loops.&lt;/strong&gt; The same question rephrased three times, or three "that didn't help" replies in a row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Make each trigger a named reason (&lt;code&gt;USER_REQUEST&lt;/code&gt;, &lt;code&gt;LOW_CONFIDENCE&lt;/code&gt;, &lt;code&gt;RESTRICTED_TOPIC&lt;/code&gt;, &lt;code&gt;BUYING_SIGNAL&lt;/code&gt;, &lt;code&gt;LOOP&lt;/code&gt;). You'll need the reason later for routing and reporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Collect the minimum before you hand over
&lt;/h2&gt;

&lt;p&gt;A handoff with no context makes the visitor repeat themselves. A handoff that demands ten form fields loses them. Ask for only what the person picking it up needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name&lt;/li&gt;
&lt;li&gt;the best contact method (email or phone) and consent to be contacted&lt;/li&gt;
&lt;li&gt;a one-line summary of the problem, which the bot can draft and the visitor can confirm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store consent as a boolean with a timestamp and the wording shown. If you serve visitors in the UK or EU, this is the kind of record your privacy team will ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Business hours change the promise, not the flow
&lt;/h2&gt;

&lt;p&gt;The bot must never promise "someone will be with you shortly" at 2 a.m. on a Sunday. Keep the flow the same, but change the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"timezone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"America/Chicago"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"hours"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"mon-fri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"09:00-18:00"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"in_hours_message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"I'm passing this to the team now. Most replies arrive within 15 minutes."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"out_of_hours_message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"The team is offline right now. I've saved your question and they'll reply by email on the next working day."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your team covers several markets (say Dallas and Toronto), keep one schedule per queue rather than one global schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The handoff payload
&lt;/h2&gt;

&lt;p&gt;This is the contract between the bot and whatever your team uses. Keep it boring and versioned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"conversation_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"c_8f2a..."&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="s2"&gt;"BUYING_SIGNAL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Wants a WhatsApp chatbot for 3 clinics, asks about timeline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"visitor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Jordan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"jordan@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"consent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"given"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2026-11-11T09:12:44Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"text_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"consent-v3"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"page_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/pricing/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"utm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"google"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"campaign"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"brand"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"transcript_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"https://internal.example.com/conversations/c_8f2a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"retrieval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"top_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"sources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/services/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/faq/"&lt;/span&gt;&lt;span class="p"&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;A few rules we follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Send a link to the transcript, not the whole transcript,&lt;/strong&gt; when the destination is email or chat. It keeps personal data in one place and lets you apply access controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include the page URL and UTM data.&lt;/strong&gt; Sales teams care which page and campaign produced the conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include the retrieval score.&lt;/strong&gt; When you review handoffs weekly, low scores show you which questions your content doesn't answer yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Route by reason
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;Target response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BUYING_SIGNAL&lt;/td&gt;
&lt;td&gt;CRM deal + sales channel alert&lt;/td&gt;
&lt;td&gt;same working hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USER_REQUEST&lt;/td&gt;
&lt;td&gt;support inbox or live chat&lt;/td&gt;
&lt;td&gt;per your support SLA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RESTRICTED_TOPIC&lt;/td&gt;
&lt;td&gt;named owner (e.g. billing)&lt;/td&gt;
&lt;td&gt;same working day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LOW_CONFIDENCE&lt;/td&gt;
&lt;td&gt;support inbox, tagged for content review&lt;/td&gt;
&lt;td&gt;same working day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LOOP&lt;/td&gt;
&lt;td&gt;support inbox, tagged "bot failure"&lt;/td&gt;
&lt;td&gt;same working day&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A webhook to an automation tool (n8n, Make or Zapier) is usually the simplest way to fan this out, because routing rules change more often than bot code.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Tell the visitor what happened
&lt;/h2&gt;

&lt;p&gt;After the handoff, the bot should say exactly three things: that a person has the conversation, when they can expect a reply, and where the reply will arrive. Then stop answering new questions in that thread, or clearly mark further answers as automated, so the visitor isn't confused about who is talking.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Measure it
&lt;/h2&gt;

&lt;p&gt;Track these weekly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;handoff rate by reason&lt;/li&gt;
&lt;li&gt;time to first human reply, split by in-hours and out-of-hours&lt;/li&gt;
&lt;li&gt;share of BUYING_SIGNAL handoffs that became a booked call or quote&lt;/li&gt;
&lt;li&gt;top 20 LOW_CONFIDENCE questions, which become your next content updates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the LOW_CONFIDENCE list keeps shrinking, the bot is getting better. If the BUYING_SIGNAL reply time keeps growing, the bot is working and your team isn't keeping up, which is a good problem but still a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should the bot try once before handing off on a restricted topic?&lt;/strong&gt;&lt;br&gt;
No. For complaints, refunds and anything legal or account-specific, a fast handoff is better than a confident wrong answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the handoff happen inside the same chat window?&lt;/strong&gt;&lt;br&gt;
Yes, if your live-chat tool supports it. Many teams start with email and a CRM task, then add in-window live chat once volume justifies staffing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What confidence threshold should we use?&lt;/strong&gt;&lt;br&gt;
There's no universal number. Start conservative, log every score, and review 50 handoffs after the first two weeks to tune it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do we need consent before collecting an email address?&lt;/strong&gt;&lt;br&gt;
Ask for consent to contact the visitor and record it. The exact wording depends on where your visitors are; check it with whoever owns privacy in your business.&lt;/p&gt;




&lt;p&gt;We design these flows as part of our &lt;a href="https://dzimark.com/usa/ai-chatbot-development/" rel="noopener noreferrer"&gt;AI chatbot development work for US businesses&lt;/a&gt;, from the handoff rules to the CRM routing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>chatbot</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shopify Checkout Customisation by Plan: What Developers Can Build Without Plus (and What Needs It)</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 05:49:13 +0000</pubDate>
      <link>https://dev.to/dzimark/shopify-checkout-customisation-by-plan-what-developers-can-build-without-plus-and-what-needs-it-4m1i</link>
      <guid>https://dev.to/dzimark/shopify-checkout-customisation-by-plan-what-developers-can-build-without-plus-and-what-needs-it-4m1i</guid>
      <description>&lt;p&gt;Sooner or later a merchant asks for something inside checkout: a delivery-date picker, a gift message, a rule that hides a payment method for certain orders, a wholesale discount nobody sells as an app. The first question isn't "how do we build it?" but "which Shopify plan does this need?", because the answer decides whether the job costs a few days or a plan upgrade.&lt;/p&gt;

&lt;p&gt;Here's how we map checkout requests to plans, based on Shopify's developer docs as of November 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three tools you'll use
&lt;/h2&gt;

&lt;p&gt;Since &lt;code&gt;checkout.liquid&lt;/code&gt; was retired, checkout is customised with three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Checkout UI extensions&lt;/strong&gt;: React-style components rendered at defined extension targets in checkout, the Thank you page and the Order status page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify Functions&lt;/strong&gt;: server-side logic (compiled to WebAssembly) that Shopify runs for discounts, delivery customisations, payment customisations, cart validation and more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkout branding&lt;/strong&gt;: colours, fonts, corner radius and so on, via the checkout editor or the GraphQL Admin API.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each one has its own plan rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: UI in the information, shipping and payment steps is Plus-only
&lt;/h2&gt;

&lt;p&gt;Shopify's docs are explicit: Checkout UI extensions for the information, shipping and payment steps are available only to stores on a Shopify Plus plan.&lt;/p&gt;

&lt;p&gt;So these are Plus jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a delivery-date or time-slot picker on the shipping step&lt;/li&gt;
&lt;li&gt;a "gift message" field that writes to an order metafield&lt;/li&gt;
&lt;li&gt;an upsell block or loyalty-points banner inside checkout&lt;/li&gt;
&lt;li&gt;B2B purchase-order number fields on the payment step&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On standard plans you can still add extensions to the &lt;strong&gt;Thank you&lt;/strong&gt; and &lt;strong&gt;Order status&lt;/strong&gt; pages. That's often enough for post-purchase surveys, tracking links or a "join our loyalty programme" prompt. Ask whether the field has to be collected &lt;em&gt;before&lt;/em&gt; payment. If it can be collected after, you may not need Plus.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Thank you page extension: works on non-Plus stores&lt;/span&gt;
&lt;span class="c1"&gt;// (React syntax; newer API versions use Shopify's Preact-based components, same targets)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reactExtension&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Banner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'@shopify/ui-extensions-react/checkout'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;reactExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purchase.thank-you.block.render'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Banner&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Track your order"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nc"&gt;You&lt;/span&gt;&lt;span class="s1"&gt;'ll get tracking by email. Questions? &amp;lt;Link to="/pages/contact"&amp;gt;Contact us&amp;lt;/Link&amp;gt;.
  &amp;lt;/Banner&amp;gt;
));
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rule 2: Functions from public apps work on all plans; custom Functions need Plus
&lt;/h2&gt;

&lt;p&gt;This is the one that catches teams out. Shopify Functions run on every plan except Starter, but only when they ship inside a &lt;strong&gt;public app&lt;/strong&gt; from the Shopify App Store. Shopify's docs state that only stores on a Shopify Plus plan can use &lt;strong&gt;custom apps&lt;/strong&gt; that contain Shopify Function APIs.&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Non-Plus route&lt;/th&gt;
&lt;th&gt;Plus route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tiered volume discount&lt;/td&gt;
&lt;td&gt;Public discount app&lt;/td&gt;
&lt;td&gt;Custom discount Function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hide COD above a threshold&lt;/td&gt;
&lt;td&gt;Public payment-customisation app&lt;/td&gt;
&lt;td&gt;Custom payment Function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename or reorder shipping rates&lt;/td&gt;
&lt;td&gt;Public delivery-customisation app&lt;/td&gt;
&lt;td&gt;Custom delivery Function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block checkout if cart breaks a rule&lt;/td&gt;
&lt;td&gt;Public validation app&lt;/td&gt;
&lt;td&gt;Custom cart/checkout validation Function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before quoting custom work for a non-Plus store, spend twenty minutes in the App Store. If a public app already implements the Function you need, the merchant gets it without upgrading. If the logic is truly bespoke (customer-tag pricing tied to an ERP, say), you're looking at Plus or a public app you'd have to build and list.&lt;/p&gt;

&lt;p&gt;A minimal discount Function input query looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="nc"&gt;RunInput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;cart&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;quantity&lt;/span&gt;
      &lt;span class="n"&gt;merchandise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="nc"&gt;ProductVariant&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;buyerIdentity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;hasAnyTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"wholesale"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&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 logic is the same on any plan. What changes is how it can be distributed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: API-driven checkout branding is Plus
&lt;/h2&gt;

&lt;p&gt;All plans can use the checkout editor's branding settings. Applying deeper branding through the GraphQL Admin API (&lt;code&gt;checkoutBrandingUpsert&lt;/code&gt;) is a Plus capability. If a brand needs pixel-level control (custom typography scales, section-level colour schemes), plan for Plus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other limits that push stores to Plus
&lt;/h2&gt;

&lt;p&gt;Checkout isn't the only reason. From Shopify's plan comparison:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API rate limits:&lt;/strong&gt; standard on Basic and Grow, up to 2x on select APIs on Advanced, up to 10x on Plus. Heavy ERP or inventory syncs feel this first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B2B catalogs:&lt;/strong&gt; up to 3 on standard plans, unlimited on Plus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headless storefronts:&lt;/strong&gt; 1 on standard plans, 25 on Plus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staff accounts:&lt;/strong&gt; 15 on Advanced, unlimited on Plus.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A quick triage checklist
&lt;/h2&gt;

&lt;p&gt;When a checkout request comes in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Does it need UI &lt;em&gt;before&lt;/em&gt; payment (information, shipping or payment step)? If yes, it needs Plus.&lt;/li&gt;
&lt;li&gt;[ ] Can it live on the Thank you or Order status page instead? Then it works on any plan.&lt;/li&gt;
&lt;li&gt;[ ] Is it discount, shipping, payment or validation logic? Search the App Store for a public app with a Function first.&lt;/li&gt;
&lt;li&gt;[ ] Is the logic bespoke to this merchant? Then it's a custom app with Functions, which needs Plus.&lt;/li&gt;
&lt;li&gt;[ ] Does it involve API-level branding, more than 3 B2B catalogs, or higher API limits? Those also need Plus.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting this right at the scoping stage saves an awkward conversation halfway through a build. If you'd rather hand the whole thing to a team that does this every week, our &lt;a href="https://dzimark.com/services/shopify-development/" rel="noopener noreferrer"&gt;Shopify development services&lt;/a&gt; cover checkout extensions and Functions on every plan, including Plus.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I still edit checkout.liquid?&lt;/strong&gt;&lt;br&gt;
No. Shopify replaced checkout.liquid with checkout extensibility (UI extensions, Functions and branding). Stores that used it had to upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do Shopify Functions work on the Basic plan?&lt;/strong&gt;&lt;br&gt;
Yes, when they come from a public App Store app. Custom apps containing Functions need Plus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a Thank you page extension enough for a post-purchase survey?&lt;/strong&gt;&lt;br&gt;
Usually yes, and it works on non-Plus plans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do I check the current rules?&lt;/strong&gt;&lt;br&gt;
Shopify's docs on checkout customisation technologies and Checkout UI extensions. Plan availability changes, so check them before quoting.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by Sushil Barwal at Dzimark, a web, Shopify and AI development company working with businesses in the USA, UK, Canada and Australia.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Magento 2 to Shopify: a data-mapping checklist for developers</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Mon, 09 Nov 2026 05:48:21 +0000</pubDate>
      <link>https://dev.to/dzimark/magento-2-to-shopify-a-data-mapping-checklist-for-developers-1839</link>
      <guid>https://dev.to/dzimark/magento-2-to-shopify-a-data-mapping-checklist-for-developers-1839</guid>
      <description>&lt;p&gt;Moving a store from Magento 2 (Magento Open Source or Adobe Commerce) to Shopify is mostly a data problem. The theme gets rebuilt anyway; what decides whether launch week goes well is how carefully the products, customers, orders and URLs were mapped. This is the checklist we use when we plan a Magento to Shopify move for stores in the US, UK, Canada and Australia.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Take an inventory before you map anything
&lt;/h2&gt;

&lt;p&gt;Export counts first, so you can check them after the import:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Products by type: simple, configurable, bundle, grouped, virtual and downloadable.&lt;/li&gt;
&lt;li&gt;Number of attribute sets and attributes actually used on the storefront.&lt;/li&gt;
&lt;li&gt;Categories, CMS pages and blog posts (if a blog extension is installed).&lt;/li&gt;
&lt;li&gt;Customers, orders, and active gift cards or store credit.&lt;/li&gt;
&lt;li&gt;Store views and websites: each one may become a separate Shopify market, language or even a separate store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These numbers become your acceptance tests. If Magento has 4,812 enabled products and Shopify shows 4,790 after import, you have something to chase before launch, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Map the catalogue model
&lt;/h2&gt;

&lt;p&gt;Magento and Shopify think about products differently.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Magento 2&lt;/th&gt;
&lt;th&gt;Shopify&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Configurable product + child simples&lt;/td&gt;
&lt;td&gt;One product with variants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configurable attributes (size, colour)&lt;/td&gt;
&lt;td&gt;Product options&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom attributes (material, care)&lt;/td&gt;
&lt;td&gt;Metafields (or metaobjects for shared data)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Categories (tree)&lt;/td&gt;
&lt;td&gt;Collections (manual or automated by rules)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle / grouped products&lt;/td&gt;
&lt;td&gt;Bundles app or Shopify's bundle features; check what fits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier and customer-group prices&lt;/td&gt;
&lt;td&gt;B2B catalogues / price lists, or an app&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Options and variants have limits.&lt;/strong&gt; Shopify caps the number of options per product and the number of variants. The limits have changed over time, so check the current numbers in Shopify's docs before you promise a product structure to the client. Products with very large attribute combinations may need splitting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide metafield definitions up front.&lt;/strong&gt; Create the definitions (type, validation, storefront access) before importing, so the theme can rely on them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Customers: plan for the password reset
&lt;/h2&gt;

&lt;p&gt;Magento stores customer passwords as hashes, and Shopify can't import them. Every migrated customer needs to activate a Shopify account or set a new password. Plan it as a communication task:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Import customers with their marketing consent status (important under UK GDPR / PECR and Canada's CASL).&lt;/li&gt;
&lt;li&gt;Send account activation invites in batches after launch, not all at once from a new sender.&lt;/li&gt;
&lt;li&gt;Put a short "We've moved, please reset your password" message on the login page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Orders: history, not live data
&lt;/h2&gt;

&lt;p&gt;Historical orders are useful for customer service and lifetime value, but they're read-only in Shopify. Decide how far back to bring them, and make sure imported orders don't trigger confirmation emails or inventory changes. Most import tools have a flag for that; test it on a development store first.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. URLs and redirects
&lt;/h2&gt;

&lt;p&gt;This is where most migrations lose traffic. Shopify uses fixed path prefixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/products/{handle}
/collections/{handle}
/pages/{handle}
/blogs/{blog}/{article}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Magento URLs such as &lt;code&gt;/mens/shirts/oxford-shirt-blue.html&lt;/code&gt; won't exist anymore. Build a redirect map:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Export every indexable Magento URL (from the sitemap and from a crawl).&lt;/li&gt;
&lt;li&gt;Pull the top landing pages from Search Console for the last 12 months.&lt;/li&gt;
&lt;li&gt;Map each old URL to the closest new URL, one to one. Avoid sending everything to the homepage.&lt;/li&gt;
&lt;li&gt;Import the map through Shopify's URL redirects CSV import.&lt;/li&gt;
&lt;li&gt;After launch, crawl the old URL list and check every one returns a 301 to a 200 page.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Redirect from,Redirect to
/mens/shirts/oxford-shirt-blue.html,/products/oxford-shirt-blue
/mens/shirts.html,/collections/mens-shirts
/customer-service,/pages/customer-service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Extensions to replace
&lt;/h2&gt;

&lt;p&gt;List every Magento extension and decide: Shopify native feature, Shopify app, custom app, or drop it. Typical ones: layered navigation (Shopify Search &amp;amp; Discovery filters), reviews, loyalty, ERP connectors, B2B quoting, and custom checkout fields (now Checkout UI extensions, some only on Shopify Plus).&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Launch-day checks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Product, customer and order counts match the inventory from step 1.&lt;/li&gt;
&lt;li&gt;Ten random products: price, tax setting, images, variants, metafields.&lt;/li&gt;
&lt;li&gt;Taxes and shipping for each market (US states, UK VAT, Canadian GST/HST, Australian GST).&lt;/li&gt;
&lt;li&gt;Redirect crawl passes; no redirect chains.&lt;/li&gt;
&lt;li&gt;Analytics and Search Console: new sitemap submitted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I keep my Magento URLs exactly?&lt;/strong&gt; No. Shopify's path prefixes are fixed, so plan 301 redirects instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do customers keep their passwords?&lt;/strong&gt; No. Passwords can't be migrated; customers activate their account or reset the password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I migrate all order history?&lt;/strong&gt; Only as far back as support and reporting need. Older data can stay in an archive export.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does it take?&lt;/strong&gt; It depends on catalogue size, extensions and integrations. Map first; then estimate.&lt;/p&gt;

&lt;p&gt;If you'd like a second pair of eyes on a migration plan, our &lt;a href="https://dzimark.com/services/shopify-migration/" rel="noopener noreferrer"&gt;Shopify migration team at Dzimark&lt;/a&gt; handles Magento, WooCommerce and BigCommerce moves for stores in the USA, UK, Canada and Australia.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>magento</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Vetting a Shopify development agency: 8 technical questions for store owners and their developers</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Sun, 08 Nov 2026 12:07:09 +0000</pubDate>
      <link>https://dev.to/dzimark/vetting-a-shopify-development-agency-8-technical-questions-for-store-owners-and-their-developers-48co</link>
      <guid>https://dev.to/dzimark/vetting-a-shopify-development-agency-8-technical-questions-for-store-owners-and-their-developers-48co</guid>
      <description>&lt;p&gt;Most "how to choose a Shopify agency" articles talk about portfolios and price. Those matter, but they don't tell you whether the code you get will still work in two years. If you run a store in the US, UK, Canada or Australia and you're about to hire an agency (or you're the in-house developer asked to vet one), these are the technical questions I'd ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Do they build on Online Store 2.0 sections, or hard-code templates?
&lt;/h2&gt;

&lt;p&gt;Modern Shopify themes use JSON templates and sections, so merchants can rearrange pages in the theme editor without a developer. Ask to see a recent theme. If every page layout is fixed in Liquid, every future change becomes a paid ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. How do they handle theme code: Git, CLI and Theme Check?
&lt;/h2&gt;

&lt;p&gt;A good agency keeps the theme in Git, uses Shopify CLI to push to a development theme, and runs Theme Check (Shopify's linter, built into the CLI as &lt;code&gt;shopify theme check&lt;/code&gt;) before release. Ask for a sample pull request. "We edit in the online code editor" is a red flag for anything beyond small fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What is their stance on apps?
&lt;/h2&gt;

&lt;p&gt;Every app adds scripts, and many leave code behind after they're uninstalled. Ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which apps would they remove, or replace with native features?&lt;/li&gt;
&lt;li&gt;Do they check theme files for leftover snippets after an app is removed?&lt;/li&gt;
&lt;li&gt;Will a custom app be built on Shopify's current APIs (the GraphQL Admin API), and who owns it?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Checkout: do they know Checkout Extensibility?
&lt;/h2&gt;

&lt;p&gt;Checkout customisation now runs through Checkout UI extensions and Shopify Functions, not through editing &lt;code&gt;checkout.liquid&lt;/code&gt;, which Shopify has retired. An agency that still promises checkout.liquid edits is out of date. Ask what they'd use for a delivery-date picker, a custom discount rule, or hiding a payment method for some customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Performance budget
&lt;/h2&gt;

&lt;p&gt;Ask how they measure speed: Core Web Vitals field data, not one Lighthouse run. Ask what they do about large images, third-party scripts and app embeds, and ask for before/after numbers from a past project.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Accessibility
&lt;/h2&gt;

&lt;p&gt;For UK, EU-facing and US stores, ask whether they test with a keyboard and a screen reader, and which WCAG level they aim for. A theme that fails keyboard navigation loses sales and invites complaints.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Migrations and SEO
&lt;/h2&gt;

&lt;p&gt;If you're moving from WooCommerce, Magento or BigCommerce, ask for their redirect plan: a URL map, Shopify's URL redirect import, and a check of your top landing pages in Search Console after launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Handover
&lt;/h2&gt;

&lt;p&gt;At the end you should get admin access that stays with you, the theme repository, app credentials in your name, and a short document of what was customised.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick scoring sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Good answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Theme architecture&lt;/td&gt;
&lt;td&gt;OS 2.0 sections, merchant-editable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow&lt;/td&gt;
&lt;td&gt;Git + Shopify CLI + Theme Check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apps&lt;/td&gt;
&lt;td&gt;Fewer apps, clean removal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout&lt;/td&gt;
&lt;td&gt;UI extensions + Functions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Field data, before/after numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handover&lt;/td&gt;
&lt;td&gt;Repo + docs + your ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want to see how we answer these, here is &lt;a href="https://dzimark.com/services/shopify-development/" rel="noopener noreferrer"&gt;Dzimark's Shopify development team&lt;/a&gt; page. We build for stores in the USA, UK, Canada and Australia.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is a Shopify Partner badge enough?&lt;/strong&gt;&lt;br&gt;
It shows the agency has a partner account. It doesn't show code quality, so still ask the questions above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should my agency edit the live theme?&lt;/strong&gt;&lt;br&gt;
No. Work should happen on a duplicate or development theme and be published after review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Shopify Plus for checkout changes?&lt;/strong&gt;&lt;br&gt;
Checkout UI extensions on the information, shipping and payment steps are a Shopify Plus feature; the thank-you and order status pages can be extended on other plans. Check Shopify's current docs for your plan.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>liquid</category>
    </item>
    <item>
      <title>Letting an AI agent act, but not alone: designing human approval steps for agent tool calls</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Sat, 07 Nov 2026 05:51:53 +0000</pubDate>
      <link>https://dev.to/dzimark/letting-an-ai-agent-act-but-not-alone-daiagentsautomationwebdevesigning-human-approval-steps-43b8</link>
      <guid>https://dev.to/dzimark/letting-an-ai-agent-act-but-not-alone-daiagentsautomationwebdevesigning-human-approval-steps-43b8</guid>
      <description>&lt;p&gt;Most AI agent demos end at the moment the agent decides to do something: send the email, refund the order, update the CRM record. In a real business, that moment is where the engineering starts. The question a client asks us most often after "can it do this?" is "what stops it doing the wrong thing?"&lt;/p&gt;

&lt;p&gt;This post is the pattern we use when we build agents for small and mid-sized businesses in the USA, UK, Canada and Australia: sort every tool the agent can call by risk, and put a person in front of the risky ones. The examples use n8n because it has human review built into its AI agent tooling, but the design works in any framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: list every tool and give it a risk level
&lt;/h2&gt;

&lt;p&gt;An agent is only as dangerous as the tools you give it. Before writing a prompt, write a table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it changes&lt;/th&gt;
&lt;th&gt;Who sees the result&lt;/th&gt;
&lt;th&gt;Can it be undone?&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;search_orders&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;the agent&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;draft_reply&lt;/td&gt;
&lt;td&gt;nothing until sent&lt;/td&gt;
&lt;td&gt;staff&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;send_email&lt;/td&gt;
&lt;td&gt;the customer's inbox&lt;/td&gt;
&lt;td&gt;the customer&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;update_crm_stage&lt;/td&gt;
&lt;td&gt;one record&lt;/td&gt;
&lt;td&gt;sales team&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;issue_refund&lt;/td&gt;
&lt;td&gt;money&lt;/td&gt;
&lt;td&gt;the customer&lt;/td&gt;
&lt;td&gt;partly&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;delete_contact&lt;/td&gt;
&lt;td&gt;one record&lt;/td&gt;
&lt;td&gt;everyone&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three questions decide the level: does it leave the building (a customer, a supplier, a regulator)? Does it move money? Can it be undone in one click? Any "yes" puts the tool in the high column.&lt;/p&gt;

&lt;p&gt;Read-only tools (search, look up, summarise) can usually run freely. Tools that write internal data can run with logging. Tools that send, pay or delete wait for a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: make the agent propose, not perform
&lt;/h2&gt;

&lt;p&gt;For high-risk tools, the agent's job changes from "do it" to "prepare it". The tool call becomes a proposal with everything a reviewer needs on one screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"issue_refund"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10482"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"the full order total"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason_from_customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Item arrived damaged, photo attached"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent_reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Policy allows a refund for damage reported within 14 days; reported on day 3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source_documents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"refund-policy.md#damaged-items"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields matter more than they look. &lt;code&gt;agent_reasoning&lt;/code&gt; lets the reviewer check the logic, not just the output. &lt;code&gt;source_documents&lt;/code&gt; points at the policy the agent relied on, which is how you catch an agent that has misread a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: put the approval where people already work
&lt;/h2&gt;

&lt;p&gt;An approval step nobody sees is a queue that never empties. Send it to the place the team already watches: a Slack or Teams channel, an email, or a simple internal form.&lt;/p&gt;

&lt;p&gt;In n8n, there are two ways to do this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Human review on agent tools.&lt;/strong&gt; n8n's documentation describes a human-in-the-loop option for AI agent tools: you can require a person to approve a tool call before it runs. This is the cleanest option, because the gate sits on the tool itself rather than in your prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send-and-wait steps.&lt;/strong&gt; Several n8n messaging nodes can send a message and pause the workflow until someone responds. That suits approvals outside the agent loop, such as a weekly batch of proposed CRM updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you use, the gate must live in the workflow, not in the system prompt. "Always ask before sending emails" in a prompt is a request; an approval node is a control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: decide what happens on "no" and on silence
&lt;/h2&gt;

&lt;p&gt;Every approval has three outcomes, and teams usually design only one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Approved:&lt;/strong&gt; the tool runs with exactly the proposed values. Don't let the agent regenerate the action after approval; run what the person saw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rejected:&lt;/strong&gt; record the reason and return it to the agent or to a person. A rejection reason is the best training data you will get for improving prompts and policies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No answer:&lt;/strong&gt; set a timeout. After it, escalate to a second person or close the request with a holding reply. Never default to "approved".&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 5: log every step
&lt;/h2&gt;

&lt;p&gt;For each agent run, keep: the user's request, the tools called with their inputs, the proposals made, who approved or rejected them and when, and the final result. n8n's execution history covers part of this; for anything customer-facing, also write a row to your own log table so the record outlives the tool's retention settings.&lt;/p&gt;

&lt;p&gt;The log is what turns "the AI did something odd" into a ten-minute investigation instead of a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: loosen the gate with evidence, not optimism
&lt;/h2&gt;

&lt;p&gt;Approval steps cost staff time, so teams want to remove them. Do it with numbers. After a few weeks, look at each high-risk tool: how many proposals were approved unchanged? If a tool's proposals are almost always approved as-is, consider moving it to medium risk with a value limit (for example, refunds under a set amount run automatically and are reviewed afterwards). If people keep editing or rejecting them, the agent needs better instructions or better data, not more freedom.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every tool has a risk level written down.&lt;/li&gt;
&lt;li&gt;High-risk tools produce a proposal with reasoning and sources.&lt;/li&gt;
&lt;li&gt;Approval happens in a channel people already watch.&lt;/li&gt;
&lt;li&gt;Approved actions run with the exact values shown.&lt;/li&gt;
&lt;li&gt;Rejections and timeouts are handled, and silence never means yes.&lt;/li&gt;
&lt;li&gt;Every run is logged outside the agent framework.&lt;/li&gt;
&lt;li&gt;Gates are relaxed only from approval data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We build agents this way for clients at Dzimark, and the &lt;a href="https://dzimark.com/ai-solutions/ai-agent-development/" rel="noopener noreferrer"&gt;AI agent development&lt;/a&gt; page shows the kinds of customer service, sales and operations agents we put these guardrails around.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Doesn't human approval defeat the point of an agent?&lt;/strong&gt;&lt;br&gt;
No. The agent still does the reading, looking up and drafting, which is most of the time spent. The person spends seconds checking a prepared action instead of minutes doing the task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I enforce approval in the prompt instead?&lt;/strong&gt;&lt;br&gt;
Not reliably. Prompts guide behaviour; they don't block it. Put the gate in the workflow or the tool layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which tools should never run without a person?&lt;/strong&gt;&lt;br&gt;
Anything that sends a message to a customer, moves money, or deletes data, at least until you have weeks of approval history showing the agent gets it right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work outside n8n?&lt;/strong&gt;&lt;br&gt;
Yes. In code-first frameworks the same idea is a tool wrapper that returns "pending approval" and resumes when a person responds.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Background reading on the Dzimark blog: &lt;a href="https://dzimark.com/guides/ai-chatbot-vs-ai-agent/" rel="noopener noreferrer"&gt;AI chatbot vs AI agent&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>security</category>
    </item>
    <item>
      <title>Test your prompts like code: a Node.js prompt test sheet for AI students</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Fri, 06 Nov 2026 06:24:37 +0000</pubDate>
      <link>https://dev.to/dzimark/test-your-prompts-like-code-a-nodaijavascriptbeginnerspromptengineeringejs-prompt-test-sheet-511b</link>
      <guid>https://dev.to/dzimark/test-your-prompts-like-code-a-nodaijavascriptbeginnerspromptengineeringejs-prompt-test-sheet-511b</guid>
      <description>&lt;p&gt;Most students learning generative AI write a prompt, try it twice and move on. Developers test. Here is a weekend project that turns prompt testing into code: a tiny Node.js script that runs one prompt over a list of test inputs and checks each answer against simple rules.&lt;/p&gt;

&lt;p&gt;It doesn't depend on any one AI provider. You plug in whichever API you have access to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you will build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cases.json&lt;/code&gt;: 10 test inputs, each with rules the answer must pass&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test.mjs&lt;/code&gt;: sends each input with your prompt, checks the rules, prints a pass/fail table&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: the test cases
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Library closes at 6 pm on Saturday from next week."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mustInclude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"6 pm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Saturday"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxWords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fee deadline for semester 5 is extended to 20 November."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mustInclude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"20 November"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxWords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt's job here: summarise a college notice in one short sentence without losing dates and times. Write at least 10 cases, including awkward ones (two dates, Hindi-English mixed text, an empty notice).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the checker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test.mjs  (Node 18+; .mjs so import and top-level await work)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&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;PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Summarise this college notice in one sentence. Keep every date and time exactly as written.&lt;/span&gt;&lt;span class="dl"&gt;"&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;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cases.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Replace with your provider's API call. It must return the model's reply as a string.&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connect your AI API here&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;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&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;problems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &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;word&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mustInclude&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`missing "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;word&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="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxWords&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxWords&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; words`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &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;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)&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;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ask&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="nx"&gt;PROMPT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\nNotice: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;passed&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FAIL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PASS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &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;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;passed&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="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; passed`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep your API key in an environment variable, never in the file, and never commit it to GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: improve the prompt, not the tests
&lt;/h2&gt;

&lt;p&gt;Run it, look at the failures, change one thing in &lt;code&gt;PROMPT&lt;/code&gt;, run again. Write down each change and the new score. After three or four rounds you will have a short, honest record like "v1 7/10, v2 9/10 (added 'keep dates exactly')". That record is a far better interview story than "I know ChatGPT".&lt;/p&gt;

&lt;h2&gt;
  
  
  Ideas to extend it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run each case 3 times, because AI answers vary.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;mustNotInclude&lt;/code&gt; rule to catch made-up details.&lt;/li&gt;
&lt;li&gt;Save results to a CSV and compare two models.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I put this together for students at Dzimark Training in Mohali, where the AI programmes cover prompt engineering, AI agents and AI-powered full-stack work. If you are in the Tricity and want the structured version, the course outline is on the &lt;a href="https://dzimarktraining.com/course/ai-programs/" rel="noopener noreferrer"&gt;Dzimark Training AI programmes page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a bilingual AI chatbot for Canadian users: English, French and the privacy checks in the code</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Fri, 06 Nov 2026 06:24:18 +0000</pubDate>
      <link>https://dev.to/dzimark/building-a-bilingual-ai-chatbot-for-canadian-users-english-french-and-the-privacy-checks-in-the-2kan</link>
      <guid>https://dev.to/dzimark/building-a-bilingual-ai-chatbot-for-canadian-users-english-french-and-the-privacy-checks-in-the-2kan</guid>
      <description>&lt;p&gt;A chatbot that works well for a shop in Dallas can still go wrong in Toronto, Montreal or Ottawa. Canadian users expect French as a first-class language (and in Quebec the law expects it), and the privacy rules that apply to the data flowing through the chat window are different from the US ones.&lt;/p&gt;

&lt;p&gt;This post is the checklist we work through when we build an AI chatbot for a Canadian business. It is written for developers, not lawyers, so treat the legal notes as pointers to read, not advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Decide where the language comes from
&lt;/h2&gt;

&lt;p&gt;You have three signals, in order of reliability:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An explicit choice.&lt;/strong&gt; A visible EN | FR toggle in the chat header. Always offer it, and remember it (a cookie or local storage key is fine).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page language.&lt;/strong&gt; If the chat is embedded on &lt;code&gt;/fr/&lt;/code&gt; pages, start in French. Read &lt;code&gt;document.documentElement.lang&lt;/code&gt; when the widget loads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser.&lt;/strong&gt; &lt;code&gt;navigator.languages&lt;/code&gt; gives you the user's preferred list. Use it only as a hint for the first greeting.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;initialLocale&lt;/span&gt;&lt;span class="p"&gt;()&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;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chat_locale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;saved&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;pageLang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageLang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pageLang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pageLang&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;pref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;languages&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;slice&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pref&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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;Then pass the locale to the model on every turn, in the system prompt, rather than hoping it copies the user's language. If a user switches language mid-conversation, follow them and update the stored value.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Keep two knowledge bases, not one translated on the fly
&lt;/h2&gt;

&lt;p&gt;If the bot answers from your own content (retrieval-augmented generation), index the English and French versions of each page separately and tag each chunk with its language. Retrieve in the user's language first and fall back to the other language only when nothing relevant comes back.&lt;/p&gt;

&lt;p&gt;Translating English chunks into French at answer time looks cheaper, but it produces inconsistent terms (product names, legal wording, prices written with the wrong decimal separator) and it makes it hard to show the source link in the right language.&lt;/p&gt;

&lt;p&gt;A practical schema for each chunk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"returns-policy#2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lang"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.ca/fr/retours/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Politique de retour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-01"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Format money, dates and phone numbers per locale
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;Intl&lt;/code&gt; instead of string templates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr-CA&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;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CAD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1499.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "1 499,50 $"&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-CA&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;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CAD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1499.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "$1,499.50"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the same for dates. Ask the model to return structured data (amount, date) and format it in your code, so a hallucinated format never reaches the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The privacy checks that change the code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PIPEDA&lt;/strong&gt; applies to private-sector organisations that collect personal information in the course of commercial activity in most of Canada. Alberta, British Columbia and Quebec have their own private-sector laws for activity inside those provinces. In practice the same engineering controls cover all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tell people what happens to the chat.&lt;/strong&gt; A one-line notice under the input box ("Chats are stored for 30 days to improve answers. Privacy policy") plus a link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect less.&lt;/strong&gt; Do not ask for a date of birth or a full address in chat unless the task needs it. Mask card numbers and health card numbers with a regex before anything is logged or sent to a model provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know where the data goes.&lt;/strong&gt; List your model provider, vector database and logging tool, and the region each one stores data in. Your client will be asked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention.&lt;/strong&gt; Put a TTL on chat logs and actually delete them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Quebec's Law 25&lt;/strong&gt; adds two things developers notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a decision about a person is made &lt;strong&gt;exclusively by automated processing&lt;/strong&gt;, the person has to be informed. A bot that only answers questions is not making decisions, but one that approves refunds or pre-qualifies leads may be. Route those to a human or disclose it.&lt;/li&gt;
&lt;li&gt;Before personal information is &lt;strong&gt;communicated outside Quebec&lt;/strong&gt; (for example to a US model API), the organisation has to assess the privacy impact. Your client's privacy lead will want the data-flow list from the previous section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Office of the Privacy Commissioner of Canada has also published principles for generative AI (December 2023) that are worth a read before you write the system prompt. The federal AI bill (AIDA, part of Bill C-27) did not become law: it died when Parliament was prorogued in January 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. French is not a feature flag in Quebec
&lt;/h2&gt;

&lt;p&gt;Quebec's Charter of the French Language, as amended by Bill 96, requires businesses to serve and inform consumers in French. For a chatbot this means French must be available, of equal quality, and not hidden behind an English-only first message. Test both languages with the same test set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;EN&lt;/th&gt;
&lt;th&gt;FR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Opening hours, holidays&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refund policy question&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handoff to a person&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prices and taxes (GST/HST/QST) formatted correctly&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  6. Handoff and logging
&lt;/h2&gt;

&lt;p&gt;Every Canadian business we work with asks the same question: what happens when the bot can't answer? Give it a tool that creates a ticket or sends the transcript to a shared inbox, in the user's language, and say so in the reply ("Je transmets votre question à notre équipe…").&lt;/p&gt;

&lt;p&gt;Log, for each conversation: locale, retrieved chunk IDs, whether a handoff happened, and the model version. That is enough to debug most bad answers without storing more personal data than you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need a separate model for French?&lt;/strong&gt; No. Current general models handle Canadian French well. What matters is French source content and a French test set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I host the chatbot data in Canada?&lt;/strong&gt; Many cloud providers offer Canadian regions for databases and logs. Model APIs vary, so check the provider's data-residency options and write down where data goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a chatbot disclosure required?&lt;/strong&gt; Telling users they are talking to an automated assistant is good practice and expected under the OPC principles. Put it in the first message.&lt;/p&gt;

&lt;p&gt;If you are scoping a chatbot like this for a Toronto, Ottawa or Montreal business, this is the work our team does on &lt;a href="https://dzimark.com/canada/ai-chatbot-development/" rel="noopener noreferrer"&gt;AI chatbot development in Canada&lt;/a&gt;, from bilingual content indexing to the handoff into your CRM.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>chatbot</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Building an AI receptionist for a small business: the call flow, the tools and the handoff</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Thu, 05 Nov 2026 05:49:35 +0000</pubDate>
      <link>https://dev.to/dzimark/building-an-ai-receptionist-for-a-small-business-the-call-flow-the-tools-and-the-handoff-1akh</link>
      <guid>https://dev.to/dzimark/building-an-ai-receptionist-for-a-small-business-the-call-flow-the-tools-and-the-handoff-1akh</guid>
      <description>&lt;p&gt;Most "AI receptionist" demos show a voice answering a phone and sounding human. That part is now the easy bit. What decides whether a plumber in Texas or a dental clinic in Ontario keeps using it after the first week is everything around the voice: what it is allowed to do, how it books, and how it hands a caller to a person.&lt;/p&gt;

&lt;p&gt;This post walks through the architecture we use when we scope an AI receptionist for a small business, with the decisions you need to make before writing any code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start with the call log, not the model
&lt;/h2&gt;

&lt;p&gt;Before choosing a speech or LLM provider, export a month of call data (most phone systems can give you call times, durations and outcomes) and listen to 20 to 30 recordings if the business already records calls lawfully.&lt;/p&gt;

&lt;p&gt;Sort the calls into buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Answerable from a fixed source&lt;/strong&gt;: opening hours, service area, prices that are published, parking, what to bring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactional&lt;/strong&gt;: book, reschedule or cancel an appointment; take a quote request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Needs a human&lt;/strong&gt;: complaints, emergencies, anything about money already owed, anything clinical or legal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two buckets are what the AI receptionist handles. The third bucket defines your handoff rules. If the third bucket is more than half the calls, an AI receptionist is probably the wrong first project.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The pipeline
&lt;/h2&gt;

&lt;p&gt;A typical inbound call goes through five stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Caller -&amp;gt; Telephony (SIP / phone number)
       -&amp;gt; Speech-to-text (streaming)
       -&amp;gt; Dialogue engine (LLM + tools + guard rails)
       -&amp;gt; Text-to-speech (streaming)
       -&amp;gt; Caller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency budget.&lt;/strong&gt; Callers notice a pause of more than about a second. Stream everything: partial transcripts into the model, partial model output into speech. Several providers now offer speech-to-speech models that collapse the middle stages; they are faster, but they make logging and guard rails harder, so check what you can inspect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Barge-in.&lt;/strong&gt; People interrupt. Your stack has to stop speaking when the caller starts, and the dialogue state has to cope with half-finished sentences.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Tools, not free text
&lt;/h2&gt;

&lt;p&gt;The model should never "promise" a booking in prose. Give it a small set of functions and make it call them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"find_slots"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date_from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date_to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"book_slot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"slot_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup_faq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"transfer_to_human"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"urgency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low|normal|urgent"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules that save you later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;book_slot&lt;/code&gt; only accepts a &lt;code&gt;slot_id&lt;/code&gt; that &lt;code&gt;find_slots&lt;/code&gt; returned in the same call. No invented times.&lt;/li&gt;
&lt;li&gt;Read the booking back to the caller and get a "yes" before calling &lt;code&gt;book_slot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lookup_faq&lt;/code&gt; answers only from the business's approved content. If there's no match, the model says it doesn't know and offers a callback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The handoff is the product
&lt;/h2&gt;

&lt;p&gt;Write the transfer rules as code, not as a hope in the system prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keywords and intents that always transfer (emergency, complaint, "speak to a person").&lt;/li&gt;
&lt;li&gt;A maximum number of failed turns (two "I didn't catch that" in a row means transfer).&lt;/li&gt;
&lt;li&gt;Business hours: outside hours, take a message with a structured summary and send it to the right inbox or CRM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you transfer, pass a summary (caller name, number, reason, what was already tried) to the human. Nothing annoys a caller more than repeating everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Consent, disclosure and recording
&lt;/h2&gt;

&lt;p&gt;This is where small-business projects get into trouble:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tell callers they are talking to an AI&lt;/strong&gt;, at the start. It is the honest default, and it avoids confusion when the call is transferred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recording and transcripts.&lt;/strong&gt; Some US states, including California, require the consent of everyone on the call before recording. In Canada, PIPEDA expects you to tell callers a call is recorded and why. In the UK, recorded calls and transcripts are personal data under UK GDPR. Australia also has federal and state rules on recording calls. Play a short notice and keep only what you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outbound calls are a different matter.&lt;/strong&gt; In February 2024 the US FCC ruled that AI-generated voices count as "artificial" voices under the TCPA, so outbound AI calls need prior express consent. Keep the receptionist inbound-only unless you have a lawyer involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Logging and review
&lt;/h2&gt;

&lt;p&gt;Store, for each call: the transcript, tool calls with arguments and results, the transfer reason, and the outcome. Review a sample weekly with the business owner for the first month. Most improvements come from adding FAQ entries and tightening transfer rules, not from changing models.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A one-week pilot plan
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Day 1: FAQ source agreed and loaded; tools wired to a test calendar.&lt;/li&gt;
&lt;li&gt;Day 2–3: internal test calls with scripted edge cases (interruptions, accents, wrong dates, angry caller).&lt;/li&gt;
&lt;li&gt;Day 4–5: live on overflow only (calls that would otherwise go to voicemail).&lt;/li&gt;
&lt;li&gt;Day 6–7: review transcripts, fix FAQ gaps, decide whether to widen it to all calls.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Starting on overflow means the worst case is "slightly better than voicemail", which is a much easier conversation with the business owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can an AI receptionist replace a front-desk person?&lt;/strong&gt;&lt;br&gt;
For routine calls it can take a large share of the load, especially after hours. It should not replace the person who handles complaints, emergencies and anything that needs judgement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which calendar systems can it book into?&lt;/strong&gt;&lt;br&gt;
Anything with an API: Google Calendar, Microsoft 365, Calendly and most practice-management or field-service systems. If the system has no API, the receptionist takes a structured message instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does it cost to run?&lt;/strong&gt;&lt;br&gt;
Running costs are per minute (telephony, speech and model usage) plus the build. Ask for an estimate based on your real call volume.&lt;/p&gt;




&lt;p&gt;At Dzimark we build these for small businesses in the USA, UK, Canada and Australia. If you want to see how we scope them, our page on &lt;a href="https://dzimark.com/ai-solutions/ai-voice-agents/" rel="noopener noreferrer"&gt;AI voice agents and AI receptionists&lt;/a&gt; has the details.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voice</category>
      <category>webdev</category>
      <category>automation</category>
    </item>
    <item>
      <title>A PHP + MySQL attendance register: a weekend project for PHP training students</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Thu, 05 Nov 2026 05:46:35 +0000</pubDate>
      <link>https://dev.to/dzimark/a-php-mysql-attendance-register-a-weekend-project-for-php-training-students-9cb</link>
      <guid>https://dev.to/dzimark/a-php-mysql-attendance-register-a-weekend-project-for-php-training-students-9cb</guid>
      <description>&lt;p&gt;Most students in PHP training write a "student management system" as their first project. It's a good idea, but it usually ends up as 30 files of copy-pasted SQL. Here is a smaller, cleaner version you can finish in a weekend: a class attendance register with PHP 8 and MySQL, using PDO and prepared statements from the first line.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The database
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;students&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;roll_no&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;attendance&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;class_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;present&lt;/span&gt; &lt;span class="nb"&gt;TINYINT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;one_mark_per_day&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;class_date&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;students&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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 &lt;code&gt;UNIQUE KEY&lt;/code&gt; stops anyone marking the same student twice on one day. Let the database enforce rules like this instead of hoping your PHP gets it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One connection file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// db.php&lt;/span&gt;
&lt;span class="nv"&gt;$pdo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PDO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'mysql:host=localhost;dbname=register;charset=utf8mb4'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'root'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ATTR_ERRMODE&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ERRMODE_EXCEPTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="no"&gt;PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ATTR_DEFAULT_FETCH_MODE&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FETCH_ASSOC&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;h2&gt;
  
  
  3. Marking attendance (safely)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="s1"&gt;'db.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'class_date'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$present&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_POST&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'present'&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="c1"&gt;// array of student ids that were ticked&lt;/span&gt;

&lt;span class="nv"&gt;$students&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT id FROM students'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'INSERT INTO attendance (student_id, class_date, present)
   VALUES (:sid, :d, :p)
   ON DUPLICATE KEY UPDATE present = VALUES(present)'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$students&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;':sid'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;':d'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;':p'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$present&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prepared statements mean a student name like &lt;code&gt;O'Brien&lt;/code&gt; (or something nastier) can never break your query. &lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; lets the teacher re-save the same day without errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The monthly report
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="s1"&gt;'db.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$pdo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'SELECT s.roll_no, s.name,
          SUM(a.present) AS days_present,
          COUNT(a.id)    AS days_marked
   FROM students s
   LEFT JOIN attendance a
     ON a.student_id = s.id AND DATE_FORMAT(a.class_date, "%Y-%m") = :m
   GROUP BY s.id
   ORDER BY s.roll_no'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;':m'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$month&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'days_marked'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'days_present'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'days_marked'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;htmlspecialchars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'roll_no'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$pct&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;br&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;Note the &lt;code&gt;htmlspecialchars&lt;/code&gt; on output: escaping on the way out is the other half of basic security.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What to add next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A login for teachers (&lt;code&gt;password_hash&lt;/code&gt; and &lt;code&gt;password_verify&lt;/code&gt;, never plain text).&lt;/li&gt;
&lt;li&gt;A CSV export of the monthly report.&lt;/li&gt;
&lt;li&gt;Rebuild it in Laravel: migrations, Eloquent models and a Blade view. You'll see how much the framework does for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put it on GitHub with a README and screenshots. In fresher interviews in Mohali and Chandigarh, being able to explain &lt;em&gt;why&lt;/em&gt; you used a unique key and prepared statements matters more than the size of the project.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I run Dzimark, and this is one of the starter projects we use at &lt;a href="https://dzimarktraining.com/course/php-development/" rel="noopener noreferrer"&gt;Dzimark Training&lt;/a&gt; in Mohali, where the full PHP and Laravel syllabus is listed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>mysql</category>
      <category>beginners</category>
    </item>
    <item>
      <title>PWA or native app? A developer's checklist for the 'web app vs mobile app' question</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Wed, 04 Nov 2026 05:54:40 +0000</pubDate>
      <link>https://dev.to/dzimark/pwa-or-native-app-a-developers-checklist-for-the-web-app-vs-mobile-app-question-59n9</link>
      <guid>https://dev.to/dzimark/pwa-or-native-app-a-developers-checklist-for-the-web-app-vs-mobile-app-question-59n9</guid>
      <description>&lt;p&gt;Clients rarely ask "should we build a PWA or a native app?". They ask "do we need an app?", and the developer in the room has to turn that into a technical decision. This is the checklist we use when we scope that question for businesses in the USA, UK, Canada and Australia. It's written for developers and tech leads, and the business-facing version is linked at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with usage frequency, not technology
&lt;/h2&gt;

&lt;p&gt;The most useful single question is: &lt;strong&gt;how often will a typical user open this?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A few times a year (quotes, renewals, one-off bookings): build for the web. Every page is indexable, nothing has to be installed, and you ship fixes the moment they merge.&lt;/li&gt;
&lt;li&gt;Weekly or daily (ordering, field work, fitness, banking): an installed app starts to pay for itself, because a home-screen icon, saved sessions and reliable notifications matter at that frequency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else on this list refines that first answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a PWA can actually do in 2026
&lt;/h2&gt;

&lt;p&gt;A progressive web app is a normal web app plus a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest" rel="noopener noreferrer"&gt;web app manifest&lt;/a&gt; and usually a service worker. That gets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Install to home screen&lt;/strong&gt; with its own window and icon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline and flaky-network support&lt;/strong&gt; through a service worker caching shell assets and data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push notifications, including on iPhone.&lt;/strong&gt; Since iOS/iPadOS 16.4, &lt;a href="https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/" rel="noopener noreferrer"&gt;Home Screen web apps can receive Web Push&lt;/a&gt;. Two constraints matter in practice: the user must add the app to the Home Screen first, and the permission prompt must follow a user gesture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal manifest looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Field Jobs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"short_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Jobs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/?source=pwa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standalone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"background_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#ffffff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"theme_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#0b5fff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"icons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/icons/192.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192x192"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/icons/512.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"512x512"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the iOS-safe way to ask for push permission is from a click handler, never on page load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#enable-alerts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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="o"&gt;=&amp;gt;&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;permission&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermission&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&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;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pushManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;userVisibleOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;applicationServerKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VAPID_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Uint8Array&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/push/subscribe&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;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&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;h2&gt;
  
  
  Where the web still falls short
&lt;/h2&gt;

&lt;p&gt;Every browser on iPhone runs on Apple's WebKit engine, so iOS support sets the ceiling. Check each "must-have" feature against MDN's compatibility tables before you promise it. Typical deal-breakers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Web / PWA&lt;/th&gt;
&lt;th&gt;Native or cross-platform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bluetooth devices, wearables&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API" rel="noopener noreferrer"&gt;Web Bluetooth&lt;/a&gt;: Chrome/Edge only, not Safari or Firefox&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliable background location&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (with store-policy justification)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large offline datasets&lt;/td&gt;
&lt;td&gt;Limited by browser storage quotas&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health data, widgets, wallet passes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push on iPhone without "Add to Home Screen"&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Camera, mic, geolocation (foreground), file upload&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If any row in the "No" column is on the day-one list, you're building an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't ship a wrapped website
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is a WebView shell around the existing site. Apple's &lt;a href="https://developer.apple.com/app-store/review/guidelines/#minimum-functionality" rel="noopener noreferrer"&gt;App Review Guideline 4.2 (Minimum Functionality)&lt;/a&gt; expects more than a repackaged website, so thin wrappers risk rejection. If you go to the stores, give the app real native value: offline mode, notifications, device features or a faster task flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture that keeps both doors open
&lt;/h2&gt;

&lt;p&gt;Whatever you build first, put the business logic behind an API:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Backend + API first&lt;/strong&gt; (auth, data, payments, admin).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive web app / PWA&lt;/strong&gt; on top. Ship, measure what people actually use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform app&lt;/strong&gt; (Flutter or React Native) on the same API for the frequent users, when the numbers justify it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the second client reuses accounts, data and rules, it costs noticeably less than the first. The mistake to avoid is business logic inside the web front end. That makes the later mobile app a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six questions to put in the brief
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;How often will a typical user open it: yearly, monthly, weekly?&lt;/li&gt;
&lt;li&gt;Where do new users come from: search and links, or store browsing?&lt;/li&gt;
&lt;li&gt;Which device features are essential on day one?&lt;/li&gt;
&lt;li&gt;How critical are notifications, and on which devices?&lt;/li&gt;
&lt;li&gt;What's the budget for version one, including yearly store fees (Apple's developer program is US$99 a year, Google Play a one-time US$25)?&lt;/li&gt;
&lt;li&gt;Who releases updates, and how often?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Weekly", "Bluetooth or background location" or "notifications are essential" point to a mobile app. Otherwise start on the web. If you'd like a second opinion on a scope, our &lt;a href="https://dzimark.com/services/mobile-app-development/" rel="noopener noreferrer"&gt;mobile app development team&lt;/a&gt; writes up both options with estimates.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can a PWA be listed in the App Store?&lt;/strong&gt; Not as a PWA. You'd need a native wrapper with real native features to pass review. Google Play accepts PWAs packaged as Trusted Web Activities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do iPhone web apps get push notifications?&lt;/strong&gt; Yes, since iOS 16.4, but only after the user adds the app to the Home Screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Flutter or React Native "native enough"?&lt;/strong&gt; For most business apps, yes. Both reach Bluetooth, background tasks and platform APIs through plugins or native modules.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://dzimark.com/guides/web-app-vs-mobile-app/" rel="noopener noreferrer"&gt;Dzimark blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pwa</category>
      <category>mobile</category>
      <category>flutter</category>
    </item>
    <item>
      <title>A college notice board in React: a first project for web design students</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Wed, 04 Nov 2026 05:43:35 +0000</pubDate>
      <link>https://dev.to/dzimark/a-college-notice-board-in-react-a-firsreactbeginnerswebdevjavascriptt-project-for-web-design-3ci9</link>
      <guid>https://dev.to/dzimark/a-college-notice-board-in-react-a-firsreactbeginnerswebdevjavascriptt-project-for-web-design-3ci9</guid>
      <description>&lt;p&gt;If you're a student learning web design and you've just finished HTML, CSS and some JavaScript, React can feel like a big jump. The best way across is a small project that you'd actually use. Here's one we give students: a college notice board.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shows a list of notices (title, department, date).&lt;/li&gt;
&lt;li&gt;Lets you filter by department.&lt;/li&gt;
&lt;li&gt;Lets you add a notice through a form.&lt;/li&gt;
&lt;li&gt;Saves notices in &lt;code&gt;localStorage&lt;/code&gt; so they survive a refresh.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's enough to touch components, props, state, events, forms, lists and effects, without a backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Set up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create vite@latest notice-board &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--template&lt;/span&gt; react
&lt;span class="nb"&gt;cd &lt;/span&gt;notice-board
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Model the data
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;starter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mid-sem exams start 18 Nov&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CSE&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-11-04&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;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Placement talk in Seminar Hall&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;All&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-11-05&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;h2&gt;
  
  
  Step 3: State that persists
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useNotices&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;notices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setNotices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;starter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&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="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notices&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notices&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;notices&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="nx"&gt;notices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setNotices&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;h2&gt;
  
  
  Step 4: Components
&lt;/h2&gt;

&lt;p&gt;Split it into &lt;code&gt;NoticeList&lt;/code&gt;, &lt;code&gt;NoticeCard&lt;/code&gt;, &lt;code&gt;DeptFilter&lt;/code&gt; and &lt;code&gt;NoticeForm&lt;/code&gt;. Pass data down as props, and pass functions like &lt;code&gt;onAdd&lt;/code&gt; down so the form can update the list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;NoticeCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;notice&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;article&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"card"&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;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;notice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&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="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;notice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dept&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; · &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;notice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="si"&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;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;article&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;h2&gt;
  
  
  Step 5: The filter
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dept&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;All&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;notices&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;notices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dept&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;dept&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dept&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;All&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;h2&gt;
  
  
  Step 6: Make it look good
&lt;/h2&gt;

&lt;p&gt;Use CSS Grid for the card layout and a single media query for mobile. Check it on your phone. Most people who view your portfolio will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stretch goals
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sort by date, newest first.&lt;/li&gt;
&lt;li&gt;Mark notices as "important" with a badge.&lt;/li&gt;
&lt;li&gt;Deploy it and put the live link on your CV.&lt;/li&gt;
&lt;li&gt;Later, replace &lt;code&gt;localStorage&lt;/code&gt; with a small API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this teaches
&lt;/h2&gt;

&lt;p&gt;By the end you have used every core React idea a junior interview asks about, and you have something finished to show. That matters more than a long list of half-done tutorials.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We teach this project in the React module of our web designing course for students in Mohali and Chandigarh. The full module list is on the &lt;a href="https://dzimarktraining.com/course/web-designing/" rel="noopener noreferrer"&gt;Dzimark Training&lt;/a&gt; course page.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>javascript</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>3 beginner n8n automation projects for students learning AI</title>
      <dc:creator>Sushil Barwal</dc:creator>
      <pubDate>Tue, 03 Nov 2026 08:27:15 +0000</pubDate>
      <link>https://dev.to/dzimark/3-beginner-n8n-automation-projects-for-students-learning-ai-3n67</link>
      <guid>https://dev.to/dzimark/3-beginner-n8n-automation-projects-for-students-learning-ai-3n67</guid>
      <description>&lt;p&gt;If you're learning AI, building a chatbot is the obvious first project. Automation is the less obvious one, and it's often what employers actually need: AI steps inside a workflow that saves someone time. &lt;a href="https://n8n.io" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; is a good place to start because you can self-host it free and see every step.&lt;/p&gt;

&lt;p&gt;Here are three projects we give students, in order of difficulty.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Form to spreadsheet to email
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trigger:&lt;/strong&gt; a form submission (n8n's Form Trigger, or a Google Form).&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Steps:&lt;/strong&gt; append the row to Google Sheets, then send a confirmation email.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;What you learn:&lt;/strong&gt; triggers, credentials, mapping fields between nodes, testing with sample data.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Inbox sorter with an AI step
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trigger:&lt;/strong&gt; new email in a test Gmail account.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Steps:&lt;/strong&gt; send the subject and body to an LLM node with a short prompt ("Classify as: enquiry, invoice, spam, other. Reply with one word."), then route with a Switch node to different labels or a Slack/Telegram message.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;What you learn:&lt;/strong&gt; prompting for structured output, handling unexpected answers (add a default branch), not sending more data to the model than needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Weekly summary with human approval
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trigger:&lt;/strong&gt; a schedule, every Monday.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Steps:&lt;/strong&gt; pull last week's rows from a sheet, ask the model to write a five-line summary, send it to yourself for approval, and only then post it to a channel.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;What you learn:&lt;/strong&gt; scheduled workflows, aggregating data, and the most important habit in AI automation: a person approves anything that goes out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use test accounts, never your personal inbox.&lt;/li&gt;
&lt;li&gt;Keep API keys in n8n credentials, not in node fields.&lt;/li&gt;
&lt;li&gt;Export each workflow as JSON and commit it to a GitHub repo with a README and a screenshot. That's your portfolio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;We teach these at Dzimark Training in Mohali as part of our &lt;a href="https://dzimarktraining.com/course/ai-programs/" rel="noopener noreferrer"&gt;AI course in Chandigarh and Mohali&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>ai</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
