<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Shrijal Acharya - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Shrijal Acharya - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 24 May 2026 22:23:54 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/shricodev/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Figma MCP vs Kombai: Cloning the Front End from Figma with AI Tools ]]>
                </title>
                <description>
                    <![CDATA[ Frontend automation is moving fast. Tools like Figma MCP and Kombai can read design context and generate working UI code. I wanted to see what you actually get in practice, so I decided to compare them. Figma MCP exposes design metadata to AI clients... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/figma-mcp-vs-kombai-frontend-clone-comparison/</link>
                <guid isPermaLink="false">693703cb19e18638588e6285</guid>
                
                    <category>
                        <![CDATA[ #ai-tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shrijal Acharya ]]>
                </dc:creator>
                <pubDate>Mon, 08 Dec 2025 16:58:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1765205804241/295ef345-b776-458a-bcdb-f1157c9c185b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Frontend automation is moving fast. Tools like Figma MCP and Kombai can read design context and generate working UI code. I wanted to see what you actually get in practice, so I decided to compare them.</p>
<p>Figma MCP exposes design metadata to AI clients, while Kombai is a frontend-first agent that integrates with editors and existing stacks.</p>
<p>In this article, we’ll feed the same two Figma files into both tools, review how close the output is to the designs, and look at the code structure in a real editor.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-whats-the-deal">What's the Deal?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-meet-the-tools">Meet the Tools</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-kombaihttpskombaicom">Kombai</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-figma-mcphttpswwwfigmacomblogintroducing-figma-mcp-server">Figma MCP</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-frontend-comparison-with-figma">Frontend Comparison with Figma</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-test-1-simple-portfolio-design">Test 1: Simple Portfolio Design</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-figma-mcp">Figma MCP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-kombai">Kombai</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-test-2-complex-learning-dashboard">Test 2: Complex Learning Dashboard</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-figma-mcp-1">Figma MCP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-kombai-1">Kombai</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-what-you-should-know-before-using-these-tools">What You Should Know Before Using These Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-final-verdict-and-whats-next">Final Verdict and What's Next?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-whats-the-deal">What's the Deal?</h2>
<p>Cloning complex Figma designs by hand isn’t fun anymore, nor is writing your CSS line by line with exact precision.</p>
<p>And sure, you can attach a screenshot or whatever to GPT, but it often ends up with something that barely looks like your design. That's where Kombai or the Figma MCP come in.</p>
<p>They actually get your Figma design metadata and give you frontend code that's super close to the real thing.</p>
<p>So now, instead of spending hours rebuilding what's already in your design file, you can focus more on small tweaks and what actually matters.</p>
<h2 id="heading-meet-the-tools">Meet the Tools</h2>
<h3 id="heading-kombaihttpskombaicom"><a target="_blank" href="https://kombai.com/">Kombai</a></h3>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xu6m5bt4wrvrttn24121.png" alt="Kombai - AI Agent for Frontend" width="1920" height="1080" loading="lazy"></p>
<p>Kombai is an AI agent designed for frontend work. It takes input from Figma (like text, images, or your existing code), understands your stack, and converts it into clean, production-ready UI.</p>
<p>💡 It’s made specifically for frontend work, so you can expect it to be very good at that (unlike more generic tools like ChatGPT or Claude).</p>
<p>Kombai also handles large repositories easily. It doesn't just convert Figma designs into code. It actually understands your entire frontend codebase, even if it's huge.</p>
<p>So, even if you're working on a small side project or a very large production app, it can read, change, and write code that fits perfectly into your existing project.</p>
<p><strong>Note:</strong> Kombai isn’t just good at cloning Figma designs and writing clean code. It actually understands your whole repo, too. You can chat with it like GPT, but it already knows your frontend. It can help refactor code, clean things up, or make changes without ever touching your backend logic.</p>
<p>Pretty handy, right?</p>
<p>No backend code is ever touched, which ensures none of your business logic is mistakenly changed.</p>
<p>You can also add Kombai right inside your editor. It works with VSCode, Cursor, Windsurf, and Trae. Just grab it from the extension marketplace, launch it, and you’re ready to go.</p>
<p>With Kombai, you can:</p>
<ul>
<li><p>Turn Figma designs into code (React, HTML, CSS, and so on) using the component library your project already uses.</p>
</li>
<li><p>Work with a frontend-smart engine that understands 30+ libraries including Next.js, MUI, and Chakra UI.</p>
</li>
<li><p>Stay in your editor, follow your own conventions, and ship faster with good accuracy.</p>
</li>
<li><p>And most importantly, preview the changes in a sandbox so you can approve or reject the change before committing it to the files.</p>
</li>
</ul>
<p>You can be up and running in under a minute. Here are the steps to get started:</p>
<ul>
<li><p>Install the extension for your editor</p>
</li>
<li><p>Sign in and connect your project</p>
</li>
<li><p>Paste a Figma link or describe what you want to build</p>
</li>
<li><p>Review the output and commit your code</p>
</li>
</ul>
<p>You can find it in the Extension marketplace of your IDE.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764351498060/13a64c1f-f3f0-4bdd-9691-45cad38688de.png" alt="Kombai - Cursor marketplace extension" class="image--center mx-auto" width="1916" height="997" loading="lazy"></p>
<p>Now, using it is just as simple as accessing it from the left sidebar and having a chat similar to how you would with ChatGPT. (Optionally, you can add your tech stack, but Kombai handles it automatically.)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1764351618867/d748f56c-c173-428b-bc80-82f0822730bf.png" alt="Kombai open inside the Cursor editor, highlighting the user interface" class="image--center mx-auto" width="1916" height="997" loading="lazy"></p>
<p>Head to the <a target="_blank" href="https://docs.kombai.com/get-started/welcome">docs</a> to get started and find the setup for your editor.</p>
<p><strong>Pricing Note</strong>: Kombai is a paid tool but gives you a free plan with 300 credits per month, which is great for personal projects. For more advanced workflows, you can move up to the Pro plan or the Enterprise plan.</p>
<p>If you spend most of your time on the frontend, Kombai may be a good fit.</p>
<h3 id="heading-figma-mcphttpswwwfigmacomblogintroducing-figma-mcp-server"><a target="_blank" href="https://www.figma.com/blog/introducing-figma-mcp-server/">Figma MCP</a></h3>
<p>Figma MCP (Model Context Protocol) lets AI agents connect directly to your Figma files. It closes the gap between your designs and your AI tools by giving them structured access to real design data instead of relying on screenshots or rough estimates.</p>
<p>It works by exposing your design's node tree, styles, layout rules, and component structure so the model can build the UI with actual design data.</p>
<p>That means tools like Claude Code, Gemini CLI, Cursor, and VSCode can actually <strong>read your designs</strong>, including layers, components, colors, spacing, and text, and use that context to generate accurate, production-ready code or design updates.</p>
<p>With Figma MCP, you can:</p>
<ul>
<li><p>Let AI tools pull live data from your Figma files, so your code suggestions always match your latest designs</p>
</li>
<li><p>Ask your AI assistant to inspect components, layouts, or styles directly from Figma</p>
</li>
<li><p>Generate UI code that reflects real design and structure instead of guessing from an image</p>
</li>
<li><p>Keep designers and developers in sync without constantly sending files back and forth.</p>
</li>
</ul>
<p>Setting it up is simple:</p>
<ul>
<li><p>Run the Figma MCP server locally</p>
</li>
<li><p>Authorize your Figma workspace</p>
</li>
<li><p>Connect your editor or AI tool (Cursor, Claude Code, Gemini CLI, and so on)</p>
</li>
</ul>
<p>For this test, I'll be using Figma MCP inside Claude Code in Linux, and setting it up is as simple as adding the following JSON in your Claude configuration file <code>~/.claude.json</code>:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"Framelink MCP for Figma"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"npx"</span>,
      <span class="hljs-attr">"args"</span>: [<span class="hljs-string">"-y"</span>, <span class="hljs-string">"figma-developer-mcp"</span>, <span class="hljs-string">"--figma-api-key=YOUR-KEY"</span>, <span class="hljs-string">"--stdio"</span>]
    }
  }
}
</code></pre>
<p>For Windows users:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"Framelink MCP for Figma"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"cmd"</span>,
      <span class="hljs-attr">"args"</span>: [<span class="hljs-string">"/c"</span>, <span class="hljs-string">"npx"</span>, <span class="hljs-string">"-y"</span>, <span class="hljs-string">"figma-developer-mcp"</span>, <span class="hljs-string">"--figma-api-key=YOUR-KEY"</span>, <span class="hljs-string">"--stdio"</span>]
    }
  }
}
</code></pre>
<p><strong>Pricing Note</strong>: To use Figma MCP, you need to have a paid Figma plan, either Professional, Organization, or Enterprise. But there's a community-maintained open-source MCP server, <a target="_blank" href="https://github.com/GLips/Figma-Context-MCP">Figma-Context-MCP</a>, that you can test out for free – which I'll be using for this test.</p>
<p>Once it’s running, any MCP-supported tool can understand your design files, making frontend coding development much more accurate.</p>
<p>Check the <a target="_blank" href="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server">Figma MCP Guide</a> to get started.</p>
<h2 id="heading-frontend-comparison-with-figma">Frontend Comparison with Figma</h2>
<p>For this test, we'll be comparing Kombai with Figma MCP using two Figma designs: one is a simple portfolio design, and the other is a more complex learner dashboard.</p>
<p><strong>NOTE:</strong> For this test with Figma MCP, I'll be using Sonnet 4, which, in my experience, has been the best model for coding the frontend. I've also tested with the recent GPT-5 and Opus 4, but Sonnet 4 seems to be the best for frontend work. If you want to try other models, feel free to do so and see if you notice much difference in the results.</p>
<blockquote>
<p>💁 <strong>Prompt</strong>: Clone this Figma design from this Figma frame link attached. Write clean, maintainable, and responsive code that matches the design closely. Keep components simple, reusable, and production-ready.</p>
</blockquote>
<p><strong>Quick note about the videos in the next section:</strong> The demo recordings are pretty long because I kept them raw. The idea is to show how the tools behave in real time. If you only care about the final output, feel free to skip to the end of each video.</p>
<h2 id="heading-test-1-simple-portfolio-design">Test 1: Simple Portfolio Design</h2>
<p>Let's start with a simpler design that doesn't have much going on in the UI.</p>
<p>You can find the Figma design template here: <a target="_blank" href="https://www.figma.com/design/ikqgqDYKWsM6OXwdz1IFCp/Personal-Portfolio-Website-Template--Community---Copy-?node-id=0-1&amp;t=HBdIdagaA7tSxpoV-1">Personal Portfolio Template</a></p>
<h3 id="heading-figma-mcp">Figma MCP</h3>
<p>Here's the response from Figma MCP:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/fyj0LT4GDVQ" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<p>This is pretty decent. The overall UI looks good, and the colors and fonts are all accurate. The biggest visual issues are with the hero image and a few icon placements, which are a bit off compared to the original Figma file.</p>
<p>The overall implementation took just about 5 minutes of coding and achieved this entire result in one go, as you see in the video demo. The time it takes isn't really dependent on the MCP itself but mostly on the model, so the timings will vary based on the model you choose to work with. The timing is something you can simply ignore here.</p>
<p>The whole page is split into sensible components (<code>Header</code>, <code>Hero</code>, <code>Projects</code>, <code>ProjectCard</code>, <code>Footer</code>) and composed in a clean <code>page.tsx</code>.</p>
<pre><code class="lang-tsx">export default function Home() {
  return (
    &lt;div className="min-h-screen bg-bg-gray"&gt;
      &lt;Header /&gt;
      &lt;main&gt;
        &lt;Hero /&gt;
        &lt;Projects /&gt;
      &lt;/main&gt;
      &lt;Footer /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>That is a nice, readable starting point for a Next app.</p>
<p>You can find the code it generated <a target="_blank" href="https://gist.github.com/shricodev/285295e78ebc41db37d0b65277abbe09">here</a>.</p>
<p>But here are some issues I noticed right away:</p>
<ol>
<li>The hero decoration is positioned with pretty brittle absolute values:</li>
</ol>
<pre><code class="lang-tsx">&lt;div className="hidden lg:block absolute right-0 top-0 w-[720px] h-[629px] pointer-events-none"&gt;
  &lt;div className="relative w-full h-full"&gt;
    &lt;div className="absolute left-0 top-0 w-[777px] h-[877px] -translate-y-[248px] bg-brand-yellow" /&gt;
    &lt;div className="absolute left-0 top-0 w-full h-full"&gt;
      &lt;img
        src="/images/hero-decoration-58b6e4.png"
        alt="Decorative"
        className="w-full h-full object-cover"
      /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p>This achieves the desired look at one screen size, but it can easily become misaligned when you resize. When compared side by side with the Figma frame, the hero image and yellow shape do not align as they should.</p>
<ol start="2">
<li>Fixed Header</li>
</ol>
<p>For a simple portfolio page with a short hero, a fixed header is not always worth the complexity.</p>
<p>The problem here is that since the header is fixed to the top, the rest of the content also starts from the top. On smaller devices, this might cover parts of the content when scrolling.</p>
<pre><code class="lang-tsx">return (
  &lt;header className="fixed top-0 left-0 right-0 bg-bg-gray z-50 h-14"&gt;
    {/* ... */}
    &lt;button
      onClick={() =&gt; scrollToSection("about")}
      className="font-raleway ..."
    &gt;
      About
    &lt;/button&gt;
    {/* more buttons */}
  &lt;/header&gt;
);
</code></pre>
<p>This is still a great head start, though it is not quite at the level where I would add it to a production repo without tidying up some of the layout changes.</p>
<h3 id="heading-kombai">Kombai</h3>
<p>Here's the response from Kombai:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/s-ocABi-V0o" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<p>Visually, this one is extremely close to the Figma template. Apart from the hero image being slightly off from the Figma design, I see no other differences. It actually feels like the design is exactly copy-pasted.</p>
<p>Notice that the font, images, and icons are exactly the same, which to me is insane.</p>
<p>You can find the code it generated <a target="_blank" href="https://gist.github.com/shricodev/41fdf0596f312573e0efd44a30b5b36b">here</a>.</p>
<p>Here are the specific things it does better in this simple example.</p>
<ol>
<li>It mirrors the Figma typography and colors as real tokens</li>
</ol>
<p>Kombai sets up <code>globals.css</code> with Figma-like tokens and even defines utility classes for the text styles:</p>
<pre><code class="lang-css"><span class="hljs-selector-pseudo">:root</span> {
  <span class="hljs-comment">/* ... */</span>
}

<span class="hljs-keyword">@theme</span> inline {
  <span class="hljs-comment">/* ... */</span>
}

<span class="hljs-keyword">@utility</span> text-heading-large {
  <span class="hljs-comment">/* ... */</span>
}

<span class="hljs-keyword">@utility</span> text-subtitle {
  <span class="hljs-comment">/* ... */</span>
}
</code></pre>
<p>That is very similar to how a designer would set up styles in Figma, and it means you can reuse these utilities in new screens instead of retyping Tailwind font sizes everywhere.</p>
<ol start="2">
<li>Components are cleaner and more reusable</li>
</ol>
<p>All the other components, like <code>Hero</code> or some smaller button components, use the same styles set up in <code>styles.css</code>.</p>
<pre><code class="lang-tsx">const baseClasses =
  "text-button px-6 py-3 rounded-sm transition-all hover:opacity-90";

const variantClasses =
  variant === "primary"
    ? "bg-(--primary-yellow) text-(--foreground)"
    : "bg-transparent border-2 border-(--foreground) text-(--foreground) hover:bg-(--foreground) hover:text-white";
</code></pre>
<p>The footer pulls each icon into its own component:</p>
<pre><code class="lang-tsx">import InstagramIcon from "./icons/InstagramIcon";
import LinkedInIcon from "./icons/LinkedInIcon";
import MailIcon from "./icons/MailIcon";
</code></pre>
<p>In practice, that means if the designer swaps the mail icon or tweaks the size, there is a single place to update it.</p>
<p>So for this simple test, Kombai’s output is both closer to the visual design and a bit nicer structurally for a real project. I would still tweak naming and some minor details, but I would happily keep most of this as is. How crazy is that?</p>
<h2 id="heading-test-2-complex-learner-dashboard">Test 2: Complex Learner Dashboard</h2>
<p>So, for the second one, let's create a slightly more complex design with a lot happening in the UI.</p>
<p>You can find the Figma design template here: <a target="_blank" href="https://www.figma.com/design/hATPCahjQRzz0dXao2QH1U/Dashboard---Online-Learning-Profile--Community-?node-id=10-1626&amp;t=sn9rVXVzXlzzdusd-0">Learning Dashboard</a></p>
<h3 id="heading-figma-mcp-1">Figma MCP</h3>
<p>Here's the response from Figma MCP:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/gyZX9s1S0EA" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<p>This is good, considering the complexity of the design. It’s able to put all the images and assets in place. This is much better than what I expected. But there's a slight inconsistency in the placement of images between the original design and the implementation, as you can see for yourself.</p>
<p>If I compare the time, this got it done super fast, in just about <strong>8 minutes</strong>, whereas Kombai took over 15 minutes to get it done (but with a better result).</p>
<p>You can find the code it generated <a target="_blank" href="https://gist.github.com/shricodev/a15cbff76f4256a20fa098d69f5b4661">here</a>.</p>
<p>Here's what I like and dislike about a few things it did here:</p>
<ol>
<li>Great smaller components, but everything is still quite page-centric</li>
</ol>
<p>It does break things into logical components like <code>Sidebar</code>, <code>Input</code>, <code>Button</code>, <code>StatCard</code>, <code>CourseCard</code>, and <code>Icons</code>. The main page then stitches them together:</p>
<pre><code class="lang-tsx">export default function Home() {
  const mentors = [
    {
      id: 1,
      name: "John Doe",
      subject: "UI/UX Design",
      color: "bg-purple-500",
    },
    // ...
  ];

  return (
    &lt;div className="flex items-center gap-8 w-full max-w-[1440px] h-[933px] bg-white rounded-[20px] mx-auto overflow-hidden"&gt;
      {/* Sidebar */}
      &lt;Sidebar /&gt;

      {/* Main content */}
      &lt;main className="flex flex-col items-center gap-6 pt-5 pb-0 flex-1 h-full overflow-hidden"&gt;
        {/* Search, hero, cards, mentor table */}
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The separation into components is nice, but everything is still wired directly inside one big page component with inline mock data. For a real app, I would want that data in its own module, ideally typed, so it is not mixed with layout logic.</p>
<ol start="2">
<li>Hard-coded dimensions tied to the original frame</li>
</ol>
<p>The outer container is pinned to a specific height:</p>
<pre><code class="lang-tsx">&lt;div className="flex items-center gap-8 w-full max-w-[1440px] h-[933px] bg-white rounded-[20px] mx-auto overflow-hidden"&gt;
</code></pre>
<p>That’s fine if you are literally recreating a 1440 by 933 frame for a screenshot, but in a live app, it means:</p>
<ul>
<li><p>You get weird empty space on taller screens.</p>
</li>
<li><p>Anything that grows vertically (longer course titles, more mentors) will either overflow or get clipped.</p>
</li>
</ul>
<p>The hero banner has the same kind of pixel-exact positioning:</p>
<pre><code class="lang-tsx">&lt;div className="relative w-full h-[181px] bg-primary rounded-[20px] overflow-hidden"&gt;
  &lt;Image
    src="/images/star1.svg"
    alt="Star"
    width={80}
    height={80}
    className="absolute top-[45px] left/[683px] opacity-25"
  /&gt;
  {/* four more star images with fixed top/left */}
&lt;/div&gt;
</code></pre>
<p>This is great for matching the specific Figma design, but as soon as the width changes, these positions stop lining up perfectly.</p>
<p>So overall, I would call this result surprisingly good for a single prompt, but a bit rigid and template-like once you start thinking about real data and using it in production.</p>
<h3 id="heading-kombai-1">Kombai</h3>
<p>Here's the response from Kombai:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/b8C3AVyz7rE" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<p>You will see in the video that I had to fix a small error with an extra prompt, but after that, it produced a fully working dashboard. The visual match is very strong, given how complex the layout is.</p>
<p>You can find the code it generated <a target="_blank" href="https://gist.github.com/shricodev/bc86951ed09c2b3ef6500cc40f3c0b0b">here</a>.</p>
<p>Here is what stands out compared to the MCP output.</p>
<ol>
<li>It treats the Figma file like a real product, not just a static screen.</li>
</ol>
<p>Instead of wiring everything in a single page with inline arrays, Kombai creates proper domain types and a <code>mock-data.ts</code>:</p>
<pre><code class="lang-tsx">import { UserProfile, Friend, Course, ProgressCard, Mentor } from "./types";

export const courses: Course[] = [
  {
    id: "1",
    title: "Beginner's Guide to becoming a professional frontend developer",
    category: "Frontend",
    thumbnail: "/images/course-coding.jpg",
    instructor: {
      name: "Prashant Kumar singh",
      role: "software Developer",
      avatar: "/images/avatar-prashant.jpg",
    },
  },
  // ...
];
</code></pre>
<p>That looks much closer to what you would expect in a production codebase: clear types, data separated from layout, and a page component that just composes everything.</p>
<ol start="2">
<li>Better mapping of the smaller UI pieces</li>
</ol>
<p>The course card is similar to the MCP one, but now it is fully driven by a <code>Course</code> object:</p>
<pre><code class="lang-tsx">export function CourseCard({ course }: { course: Course }) {
  return (
    &lt;div className="flex flex-col gap-2.5 rounded-[20px] bg-white shadow-[0px_14px_42px_rgba(8,15,52,0.06)] overflow-hidden min-w-[268px]"&gt;
      &lt;div className="relative"&gt;
        &lt;Image
          src={course.thumbnail}
          alt={course.title}
          width={244}
          height={113}
          className="w-full h-28 object-cover rounded-t-xl"
        /&gt;
        &lt;button className="absolute top-3 right-3 w-2 h-2 bg-white rounded-full" /&gt;
      &lt;/div&gt;
      &lt;div className="px-3 pb-4 flex flex-col gap-2.5"&gt;
        &lt;span className="text-[8px] font-normal uppercase text-primary px-3 py-1 bg-purple-50 rounded w-fit"&gt;
          {course.category}
        &lt;/span&gt;
        &lt;p className="text-[14px] font-medium text-text-primary leading-tight"&gt;
          {course.title}
        &lt;/p&gt;
        &lt;div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden"&gt;
          &lt;div
            className="h-full bg-primary rounded-full"
            style={{ width: "60%" }}
          /&gt;
        &lt;/div&gt;
        {/* instructor avatar and name */}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The structure and text styles are very close to the original design, and because the card is fully data-driven, you can plug in real data without touching the JSX.</p>
<ol start="3">
<li>Design tokens and typography utilities again</li>
</ol>
<p>Just like in the portfolio example, Kombai sets up a proper token layer for the dashboard:</p>
<pre><code class="lang-css"><span class="hljs-selector-pseudo">:root</span> {
  <span class="hljs-comment">/* ... */</span>
}

<span class="hljs-keyword">@utility</span> heading-section {
  <span class="hljs-comment">/* ... */</span>
}

<span class="hljs-keyword">@utility</span> text-caption {
  <span class="hljs-comment">/* ... */</span>
}
</code></pre>
<p>The components then reuse these utilities, which keeps the code close to the design system instead of scattering font sizes and colors everywhere.</p>
<ol start="4">
<li>Things I would still tweak</li>
</ol>
<p>It is not perfect:</p>
<ul>
<li><p>The Next <code>layout.tsx</code> is still using the default Geist fonts and “Create Next App” metadata, so you would want to align that with the Inter font and real app title.</p>
</li>
<li><p>Some of the mock data has inconsistent casing in names and roles, which you would clean up in a real project.</p>
</li>
<li><p>The play button on the course card is just a white dot button for now, so you would still plug in the real icon.</p>
</li>
</ul>
<p>But even with those issues, it is very close to something I would actually keep in a production repo after a quick pass.</p>
<p>Now, this is not as perfect as the previous Kombai implementation, and it did not run into errors. But considering how complex this design is, with multiple different cards with images and all, it's still really impressive to me.</p>
<p>For this one, it took a bit longer to code, but in my opinion, the extra time was worth it.</p>
<p>Imagine you're building something similar and get a response this good already. Then it's not that big of a deal to iterate a little bit, right? You don't have to start from scratch. Just make a few changes if required, and you're done.</p>
<h2 id="heading-what-you-should-know-before-using-these-tools">What You Should Know Before Using These Tools</h2>
<p>As good as these tools are, they’re not something you can just trust blindly. They’ll get you off to a solid start, but you’ll still need to tweak a few things before calling it production-ready.</p>
<p><strong>Kombai</strong> does a great job cloning Figma designs and writing clean, modular code. It breaks components into smaller files and generally follows good structure.</p>
<p>The only issue I noticed is that it sometimes slips on naming conventions. Since it scans your entire codebase to stay consistent with your setup, it can be a bit slower to generate code, but that’s also what makes it smarter. You’re not just getting a Figma cloner, you’re getting an assistant that actually understands your frontend.</p>
<p><strong>Figma MCP</strong> is fast and does a decent job matching the UI, although the results depend a lot on the model you use for generation. If your main goal is to clone Figma designs quickly and you don’t mind refining the output, it’s a good option.</p>
<p>In short, both tools can save you a ton of time, but they’re not plug-and-play replacements for a frontend workflow. Treat them as part of your toolkit, and you’ll get the best results.</p>
<h2 id="heading-final-verdict-and-whats-next">Final Verdict, and What's Next?</h2>
<p>Now that you’ve got the gist of what these tools can do, go ahead and try them out. You can turn your Figma designs into working frontends in just a few minutes without all the endless play with CSS.</p>
<p>To sum up, here’s the quick rundown:</p>
<ul>
<li><p>If you want production-ready code that actually looks like your Figma design and you mostly live in VS Code, Cursor, or any GUI IDE, go with Kombai. It nails the details and even understands your codebase, which is completely missing in Figma MCP.</p>
</li>
<li><p>If you just want to clone a Figma design quickly and don’t mind if things are <em>slightly</em> off, Figma MCP is totally fine. It gets the job done pretty well.</p>
</li>
</ul>
<p>Basically, choose Kombai if you care about precision and code quality with codebase understanding.</p>
<p>Choose Figma MCP if you want something quick, that <em>works</em> and looks decent enough. 🤷‍♂️</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>So, what do you think? Pretty cool, right? This was a fun little experiment to see how close tools like Figma MCP and Kombai can get to cloning real frontends straight from Figma.</p>
<p>If you’re into building frontends and want to save yourself a few hours of CSS pain, definitely give them a try. Just don’t expect them to be perfect in one try – their output still needs review and likely a little refining.</p>
<p>That’s all for this one. Thank you for reading! ✌️</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Google Sheet AI Agent with Composio and Gemini TTS Support ]]>
                </title>
                <description>
                    <![CDATA[ With the rise of AI agents and agentic systems, we’re no longer just generating text or images, we’re teaching AI how to take actions. Instead of asking, “Can AI write this for me?” you can now ask, “Can AI do this for me?” From updating CRMs to mana... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-google-sheet-ai-agent/</link>
                <guid isPermaLink="false">68d6a166d140a408e4a60858</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shrijal Acharya ]]>
                </dc:creator>
                <pubDate>Fri, 26 Sep 2025 14:21:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1758896336162/ed8b3c6b-2b3a-49ad-b60d-b2a42efbe19e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>With the rise of AI agents and agentic systems, we’re no longer just generating text or images, we’re teaching AI how to take actions. Instead of asking, “Can AI write this for me?” you can now ask, “Can AI do this for me?” From updating CRMs to managing tasks, agents can now connect to real tools and get things done.</p>
<p>In this article, you’ll build an AI agent that can talk, think, and even update your Google Sheets using Composio, Next.js, and Gemini TTS.</p>
<h2 id="heading-whats-covered">What's Covered?</h2>
<p>In this tutorial, you'll learn how to build your own AI agent for Google Sheets with voice support that can use tools from Composio. You’ll learn these along the way:</p>
<ul>
<li><p>What an AI Agent is</p>
</li>
<li><p>How to use Composio to add integrations to your agent.</p>
</li>
<li><p>How to stream responses from a Next.js API route with Vercel AI SDK.</p>
</li>
<li><p>How to work with the Gemini text-to-speech API.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-whats-covered">What's Covered?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-whats-this-sheet-agent">What’s this Sheet Agent?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-core-components-in-the-application">Core Components in the Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-google-sheet-agent-in-action">Google Sheet Agent in Action</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-whats-this-sheet-agent">What’s this Sheet Agent?</h2>
<p>First, what is an AI Agent? An AI agent is a system that can act independently to achieve goals. For example, it can book a flight, send an email, or search a database.</p>
<p>Generative AI, like ChatGPT, mainly focuses on creating output such as text, images, or code. An agent is different because it can make decisions, plan, and take actions in the real world, not just generate content.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1757925559119/187f2b32-ddf0-46fb-8359-8cb62699da57.webp" alt="Working of an AI Agent" class="image--center mx-auto" width="2453" height="1224" loading="lazy"></p>
<p>Large language models (LLMs) often power these agents. The LLM provides reasoning and conversation skills, while the agent layer adds tools that enable it to act beyond simple generation.</p>
<p>So, you might have guessed it. Today, we're building an AI agent that can access real data from Google Sheets and even make changes to it.</p>
<h2 id="heading-how-to-set-up-the-project">How to Set Up the Project</h2>
<p>It's fairly simple to get this project up and running. Follow these steps:</p>
<p>First, you need to clone the repository:</p>
<pre><code class="lang-bash">git <span class="hljs-built_in">clone</span> https://github.com/shricodev/google-sheet-super-agent.git
<span class="hljs-built_in">cd</span> google-sheet-super-agent
</code></pre>
<p>Next, you need to install the dependencies:</p>
<pre><code class="lang-bash">npm install
</code></pre>
<p>Then set up the environment variables and run the development server:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># API key for Google Gemini (direct access)</span>
GEMINI_API_KEY=

<span class="hljs-comment"># API key for Composio to access tool integrations (especially Google Sheets)</span>
COMPOSIO_API_KEY=

<span class="hljs-comment"># Composio user ID (get this from your Composio dashboard after login)</span>
COMPOSIO_GOOGLE_SHEET_USER_ID=

<span class="hljs-comment"># Auth config ID for Google Sheets inside Composio</span>
GOOGLE_SHEETS_AUTH_CONFIG_ID=

<span class="hljs-comment"># API key for Google Generative AI SDK (Gemini SDK client)</span>
GOOGLE_GENERATIVE_AI_API_KEY=

<span class="hljs-comment"># Secret key for signing/encrypting sessions.</span>
<span class="hljs-comment"># Generate with `openssl rand -base64 32`</span>
SESSION_SECRET=&lt;secret_key_for_session&gt;
</code></pre>
<p>To get the Composio API key, create an <a target="_blank" href="https://platform.composio.dev/auth">account</a> and log in to the dashboard. You can find the API key in your default project settings.</p>
<p>For the <code>COMPOSIO_GOOGLE_SHEET_USER_ID</code>, you can obtain it after connecting an account in the Google Sheets Auth configuration in Composio.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1757925645287/a727f2d0-c151-4dea-96bf-3d2b6317cc8d.png" alt="Google Sheets account connection button in Composio" class="image--center mx-auto" width="1862" height="962" loading="lazy"></p>
<h2 id="heading-core-components-in-the-application">Core Components in the Application</h2>
<p>There are mainly three core logical components in this project:</p>
<h3 id="heading-1-initiate-connection">1. Initiate Connection</h3>
<p>This is fairly straightforward. You need to initiate a connection with Composio to use the integrations, which in our case is Google Sheets.</p>
<pre><code class="lang-tsx">// ...Rest of the code

const connection = await composio.connectedAccounts.initiate(
  userID,
  googleSheetAuthConfigID,
  // Comment this out if you want to allow multiple accounts
  // {
  //   allowMultiple: true,
  // },
);

infoLog(
  "Please visit the following URL to authorize: ",
  connection.redirectUrl ? connection.redirectUrl : "Something went wrong!",
);
</code></pre>
<h3 id="heading-2-set-up-tts-with-gemini-api">2. Set up TTS with Gemini API</h3>
<p>For this project, I decided to go with Gemini for TTS generation instead of OpenAI only because they recently (end of August 2025) launched their TTS API.</p>
<p>You can read more about it here: <a target="_blank" href="https://ai.google.dev/gemini-api/docs/speech-generation">Gemini Speech Generation (text-to-speech)</a>.</p>
<pre><code class="lang-tsx">import { errorLog } from "@/lib/logger";
import { ttsSchema } from "@/lib/validators/tts";
import { GoogleGenAI } from "@google/genai";
import { StatusCodes } from "http-status-codes";
import { NextRequest, NextResponse } from "next/server";
import { Readable } from "stream";
import wav from "wav";

const ai = new GoogleGenAI({
  apiKey: process.env.GEMINI_API_KEY,
});

async function convertL16ToWav(pcmBuffer: Buffer): Promise&lt;Buffer&gt; {
  return new Promise((resolve, reject) =&gt; {
    const chunks: Buffer[] = [];

    const writer = new wav.Writer({
      channels: 1,
      sampleRate: 24000,
      bitDepth: 16,
    });

    writer.on("data", (chunk) =&gt; {
      chunks.push(chunk);
    });

    writer.on("end", () =&gt; {
      resolve(Buffer.concat(chunks));
    });

    writer.on("error", reject);

    const readable = new Readable({
      read() {
        this.push(pcmBuffer);
        this.push(null); // End the stream
      },
    });

    readable.pipe(writer);
  });
}

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const parsedBody = ttsSchema.safeParse(body);

    if (!parsedBody.success) {
      return NextResponse.json(
        {
          error: parsedBody.error.message,
        },
        { status: StatusCodes.BAD_REQUEST },
      );
    }

    const { text } = parsedBody.data;

    const result = await ai.models.generateContent({
      model: "gemini-2.5-flash-preview-tts",
      contents: [{ parts: [{ text: text }] }],
      config: {
        responseModalities: ["AUDIO"],
        speechConfig: {
          voiceConfig: {
            prebuiltVoiceConfig: { voiceName: "Kore" },
          },
        },
      },
    });

    const data = result.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
    const mimeType =
      result.candidates?.[0]?.content?.parts?.[0]?.inlineData?.mimeType;

    if (typeof data !== "string") {
      errorLog("Invalid audio data received:", { data, mimeType });
      return NextResponse.json(
        { error: "Audio data is not a string." },
        { status: StatusCodes.INTERNAL_SERVER_ERROR },
      );
    }

    if (!data || data.length === 0) {
      errorLog("Empty audio data received:", { data, mimeType });
      return NextResponse.json(
        { error: "Empty audio data received." },
        { status: StatusCodes.INTERNAL_SERVER_ERROR },
      );
    }

    try {
      const audioBuffer = Buffer.from(data, "base64");

      console.log("Generated audio:", {
        bufferSize: audioBuffer.length,
        contentType: mimeType || "unknown",
        mimeType,
        textLength: text.length,
      });

      // Check if it's L16 PCM format that needs conversion
      if (
        mimeType?.startsWith("audio/L16") ||
        mimeType?.startsWith("audio/l16")
      ) {
        const wavBuffer = await convertL16ToWav(audioBuffer);

        return new NextResponse(new Uint8Array(wavBuffer), {
          headers: {
            "Content-Type": "audio/wav",
            "Content-Length": wavBuffer.length.toString(),
            "Cache-Control": "no-cache",
            "Accept-Ranges": "bytes",
          },
        });
      }

      return new NextResponse(new Uint8Array(audioBuffer), {
        headers: {
          "Content-Type": mimeType || "audio/mpeg",
          "Content-Length": audioBuffer.length.toString(),
          "Cache-Control": "no-cache",
          "Accept-Ranges": "bytes",
        },
      });
    } catch (bufferError) {
      errorLog(bufferError, "API /tts (buffer error)");
      return NextResponse.json(
        { error: "Invalid base64 audio data." },
        { status: StatusCodes.INTERNAL_SERVER_ERROR },
      );
    }
  } catch (error) {
    errorLog(error, "API /tts");
    return NextResponse.json(
      { message: "Error generating audio." },
      { status: 500 },
    );
  }
}
</code></pre>
<p>This one's a bit more involved. For some reason, Gemini's API returns the data in the <code>audio/L16</code> format and not in the <code>mp3</code> or <code>wav</code> format that we're used to using.</p>
<p>And you can't really play this audio format directly in your browser. So, first, we need to convert it to <code>wav</code> format using the <code>convertL16ToWav</code> function. Then, we can return the <code>wav</code> buffer as the response.</p>
<p>This took me forever to implement. I didn't know there was something like <code>audio/L16</code> that I couldn't play in my browser. I had to do a lot of googling to figure this one out.</p>
<p>All in all, all it's doing is wrap the raw audio in a WAV file that looks like mono, 24kHz, 16-bit PCM.</p>
<p>And if you want to use the OpenAI package, which is much easier to use as it returns the speech in <code>mp3</code> format, check out this project of mine: <a target="_blank" href="https://github.com/shricodev/voice-chat-ai-configurable-agent/blob/main/app/api/tts/route.ts">shricodev/voice-chat-ai-agent (TTS)</a>.</p>
<h3 id="heading-3-handle-user-queries">3. Handle User Queries</h3>
<p>This is the last piece of the puzzle. Here's where the actual tool call logic happens.</p>
<pre><code class="lang-tsx">import { google } from "@ai-sdk/google";
import { streamText } from "ai";
import { Composio } from "@composio/core";
import { NextResponse } from "next/server";
import { chatSchema } from "@/lib/validators/chat";
import { StatusCodes } from "http-status-codes";
import { errorLog } from "@/lib/logger";
import { VercelProvider } from "@composio/vercel";

// ...Rest of the code

const tools = await composio.tools.get(userID, {
  toolkits: ["GOOGLESHEETS"],
});

let conversationContext = "";
if (conversationHistory &amp;&amp; conversationHistory.length &gt; 0) {
  conversationContext = conversationHistory
    .map((conversation) =&gt; {
      return `${conversation.role}: ${conversation.content}`;
    })
    .join("\n");
}

const systemPrompt = `
You are an intelligent Google Sheets assistant. You can help users analyze, query, and manipulate data in their Google Sheets.

Sheet ID: ${sheetID}
User ID: ${userID}

Guidelines:
- Always use the Google Sheets tools to access real data from the spreadsheet
- Provide clear, actionable insights based on the actual data
- If you need to read data, use the appropriate Google Sheets tools first
- Format your responses in a clear, professional manner
- If asked about calculations, use the actual data from the sheet

Always generate a short summary of what you got done. like if the user asked
you to make changes, then write in short about what all changes you did. If
they asked you to summarize the data, then write in short about what the data
is all about.

---

Previous conversation in this document:

${conversationContext}
`;

const result = streamText({
  model: google("gemini-2.5-pro"),
  system: systemPrompt,
  prompt,
  tools: tools,
  toolChoice: "auto",
});

return result.toUIMessageStreamResponse({ sendReasoning: true });
</code></pre>
<p>This code lives in the Next.js app router. First, we fetch the tools from Composio using the <code>composio.tools.get</code> function. We use <code>auto</code> as the tool choice, which means that the agent will use the tools it has the most confidence in.</p>
<p>Then, we create the system prompt that will guide the agent on how to behave.</p>
<p>Finally, we call the <code>streamText</code> function, which streams the response instead of waiting for the entire response before sending it to the client, passing in the tools, system prompt, and the model to use. Then, we send the response in the <code>UIMessageStreamResponse</code> format so it can be easily displayed on the UI.</p>
<h2 id="heading-google-sheet-agent-in-action">Google Sheet Agent in Action</h2>
<p>Here's a quick demo of the agent in action:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/emXE8q1Irao" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>So, what do you think of the project so far? This was a really fun project for me to work on.</p>
<p>Go ahead, clone the repository, and give it a try with your Google Sheet. Even after all of this, it is a fairly small project with super simple logic, which I believe you've already understood completely.</p>
<p>Do I suggest you use it on an important Google Sheet? Not at all. Remember, it's just an AI model that can access tools from Composio. You can never be 100% sure with AI. While building this project, I did run into cases where the AI picked the wrong tools and even messed up the sheet entirely. But, you can always try it on a not-so-important sheet to see how it all works.</p>
<p>You can find the entire source code here: <a target="_blank" href="https://github.com/shricodev/google-sheet-super-agent">shricodev/google-sheet-super-agent</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a LangGraph and Composio-Powered Discord Bot ]]>
                </title>
                <description>
                    <![CDATA[ With the rise of AI tools over the past couple years, most of us are learning how to use them in our projects. And in this article, I’ll teach you how to build a quick Discord bot with LangGraph and Composio. You’ll use LangGraph nodes to build a bra... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-langgraph-composio-powered-discord-bot/</link>
                <guid isPermaLink="false">685b12877dabc4d300e53706</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ langgraph ]]>
                    </category>
                
                    <category>
                        <![CDATA[ bot ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shrijal Acharya ]]>
                </dc:creator>
                <pubDate>Tue, 24 Jun 2025 21:03:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1750798930964/65dd7078-e4e7-42d0-a797-1e7d72690513.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>With the rise of AI tools over the past couple years, most of us are learning how to use them in our projects. And in this article, I’ll teach you how to build a quick Discord bot with LangGraph and Composio.</p>
<p>You’ll use LangGraph nodes to build a branching flow that processes incoming messages and detects intent like chat, support, or tool usage. It’ll then route them to the right logic based on what the user says.</p>
<p>I know it may sound a bit weird to use LangGraph for a Discord bot, but you’ll soon see that this project is a pretty fun way to visualize how node-based AI workflows actually run.</p>
<p>For now, the workflow is simple: you’ll figure out if the user is just chatting, asking a support question, or requesting that the bot perform an action, and respond based on that.</p>
<p><strong>What you will learn:</strong> 👀</p>
<ul>
<li><p>How to use LangGraph to create an AI-driven workflow that powers your bot’s logic.</p>
</li>
<li><p>How you can integrate Composio to let your bot take real-world actions using external tools.</p>
</li>
<li><p>How you can use Discord.js and handle different message types like replies, threads, and embeds.</p>
</li>
<li><p>How you can maintain per-channel context using message history and pass it into AI.</p>
</li>
</ul>
<p>By the end of this article, you’ll have a quite decent and functional Discord bot that you can add to your server. It replies to users based on message context and even has tool-calling support! (And there’s a small challenge for you to implement something yourself.) 😉</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Make sure you have Discord installed on your machine so you can test the bot easily.</p>
<p>This project is designed to demonstrate how you can build a bot powered by LangGraph and Composio. Before proceeding, it is helpful to have a basic understanding of:</p>
<ul>
<li><p>How to work with Node.js</p>
</li>
<li><p>Rough idea of what LangGraph is and how it works</p>
</li>
<li><p>How to work with Discord.js</p>
</li>
<li><p>What AI Agents are</p>
</li>
</ul>
<p>If you’re not confident about any of these, try following along anyway. You might pick things up just fine. And if it ever gets confusing, you can always check out the full source code <a target="_blank" href="https://github.com/shricodev/discord-bot-langgraph-composio">here</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-environment">How to Set Up the Environment</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-initialize-the-project">Initialize the Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-install-dependencies">Install Dependencies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-composio">Configure Composio</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-discord-integration">Configure Discord Integration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-add-environment-variables">Add Environment Variables</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-build-the-application-logic">Build the Application Logic</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-define-types-and-utility-helpers">Define Types and Utility Helpers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-implement-langgraph-workflow">Implement LangGraph Workflow</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-set-up-discordjs-client">Set Up Discord.js Client</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-environment">How to Set Up the Environment</h2>
<p>In this section, we will get everything set up for building the project.</p>
<h3 id="heading-initialize-the-project">Initialize the Project</h3>
<p>Initialize a Node.js application with the following command:</p>
<p>💁 Here I'm using Bun, but you can choose any package manager of your choice.</p>
<pre><code class="lang-bash">mkdir discord-bot-langgraph &amp;&amp; <span class="hljs-built_in">cd</span> discord-bot-langgraph \
&amp;&amp; bun init -y
</code></pre>
<p>Now, that our Node.js application is ready, let's install some dependencies.</p>
<h3 id="heading-install-dependencies">Install Dependencies</h3>
<p>We'll be using the following main packages and some other helper packages:</p>
<ul>
<li><p><a target="_blank" href="https://discord.js.org">discord.js</a>: Interacts with the Discord API</p>
</li>
<li><p><a target="_blank" href="https://composio.dev">composio</a>: Adds tools integration support to the bot</p>
</li>
<li><p><a target="_blank" href="https://platform.openai.com">openai</a>: Enables AI-powered responses</p>
</li>
<li><p><a target="_blank" href="https://www.langchain.com">langchain</a>: Manages LLM workflows</p>
</li>
<li><p><a target="_blank" href="https://zod.dev">zod</a>: Validates and parses data safely</p>
</li>
</ul>
<pre><code class="lang-bash">bun add discord.js openai @langchain/core @langchain/langgraph \
langchain composio-core dotenv zod uuid
</code></pre>
<h3 id="heading-configure-composio">Configure Composio</h3>
<p>💁 You’ll use Composio to add integrations to your application. You can choose the integration of your choice, but here I'm using Google sheets.</p>
<p>First, before moving forward, you need to get access to a Composio API key.</p>
<p>Go ahead and create an account on Composio, get your API key, and paste it in the <code>.env</code> file in the root of the project:</p>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lkr1pys0txedp9vam4tt.png" alt="Composio dashboard" width="1920" height="972" loading="lazy"></p>
<pre><code class="lang-ini"><span class="hljs-attr">COMPOSIO_API_KEY</span>=&lt;your_composio_api_key&gt;
</code></pre>
<p>Authenticate yourself with the following command:</p>
<pre><code class="lang-bash">composio login
</code></pre>
<p>Once that’s done, run the <code>composio whoami</code> command, and if you see something like the below, you’re successfully logged in.</p>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ifzbkw6u6bwnj68lwqxt.png" alt="Output of the `composio whoami` command" width="1115" height="304" loading="lazy"></p>
<p>You're almost there: now you just need to set up integrations. Here, I’ll use Google sheets, but again you can set up any integration you like.</p>
<p>Run the following command to set up the Google Sheets integration:</p>
<pre><code class="lang-bash">composio add googlesheets
</code></pre>
<p>You should see an output similar to this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750336813743/9079ef2b-dc2a-4b10-b001-50e4cf98f3c5.png" alt="Add Composio Google Sheets integration" class="image--center mx-auto" width="1457" height="384" loading="lazy"></p>
<p>Head over to the URL that’s shown, and you should be authenticated like so:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750325571006/b0864445-7471-471f-88eb-f2ec8d832b39.png" alt="Composio authentication success" class="image--center mx-auto" width="1916" height="947" loading="lazy"></p>
<p>That's it. You’ve successfully added the Google Sheets integration and can access all its tools in your application.</p>
<p>Once finished, run the <code>composio integrations</code> command to verify if it worked. You should see a list of all your integrations:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750325653419/4585b63a-5581-4102-92e4-a55dca018063.png" alt="Composio list of integrations" class="image--center mx-auto" width="1175" height="268" loading="lazy"></p>
<h3 id="heading-configure-discord-integration">Configure Discord Integration</h3>
<p>This is a bit off topic for this tutorial, but basically, you’ll create an application/bot on Discord and add it to your server.</p>
<p>You can find a guide on how to create and add a bot to your server in the <a target="_blank" href="https://discordjs.guide/preparations/adding-your-bot-to-servers.html#bot-invite-links">Discord.js</a> documentation.</p>
<p>And yes, it’s free if you’re wondering whether any step here requires a pro account or anything. 😉</p>
<p>Make sure you populate these three environment variables:</p>
<pre><code class="lang-ini"><span class="hljs-attr">DISCORD_BOT_TOKEN</span>=&lt;YOUR_DISCORD_BOT_TOKEN&gt;
<span class="hljs-attr">DISCORD_BOT_GUILD_ID</span>=&lt;YOUR_DISCORD_BOT_GUILD_ID&gt;
<span class="hljs-attr">DISCORD_BOT_CHANNEL_ID</span>=&lt;YOUR_DISCORD_BOT_CHANNEL_ID&gt;
</code></pre>
<h3 id="heading-add-environment-variables">Add Environment Variables</h3>
<p>You’ll require a few other environment variables, including the OpenAI API key, for the bot to work.</p>
<p>Your final <code>.env</code> file should look something like this:</p>
<pre><code class="lang-ini"><span class="hljs-attr">OPENAI_API_KEY</span>=&lt;YOUR_OPENAI_API_KEY&gt;

<span class="hljs-attr">COMPOSIO_API_KEY</span>=&lt;YOUR_COMPOSIO_API_KEY&gt;

<span class="hljs-attr">DISCORD_BOT_TOKEN</span>=&lt;YOUR_DISCORD_BOT_TOKEN&gt;
<span class="hljs-attr">DISCORD_BOT_GUILD_ID</span>=&lt;YOUR_DISCORD_BOT_GUILD_ID&gt;
<span class="hljs-attr">DISCORD_BOT_CHANNEL_ID</span>=&lt;YOUR_DISCORD_BOT_CHANNEL_ID&gt;
</code></pre>
<h2 id="heading-build-the-application-logic">Build the Application Logic</h2>
<p>Now that you’ve laid all the groundwork, you can finally start coding the project.</p>
<h3 id="heading-define-types-and-utility-helpers">Define Types and Utility Helpers</h3>
<p>Let’s start by writing some helper functions and defining the types of data you’ll be working with.</p>
<p>It's important in any application, especially ones like the one we're building – which is prone to errors due to multiple API calls – that we set up decent logging so we know when and how things go wrong.</p>
<p>Create a new file named <code>logger.ts</code> inside the <code>utils</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/utils/logger.ts</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> DEBUG = <span class="hljs-string">"DEBUG"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> INFO = <span class="hljs-string">"INFO"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> WARN = <span class="hljs-string">"WARN"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> ERROR = <span class="hljs-string">"ERROR"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> LogLevel = <span class="hljs-keyword">typeof</span> DEBUG | <span class="hljs-keyword">typeof</span> INFO | <span class="hljs-keyword">typeof</span> WARN | <span class="hljs-keyword">typeof</span> ERROR;

<span class="hljs-comment">// eslint-disable-next-line  @typescript-eslint/no-explicit-any</span>
<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">log</span>(<span class="hljs-params">level: LogLevel, message: <span class="hljs-built_in">string</span>, ...data: <span class="hljs-built_in">any</span>[]</span>) </span>{
  <span class="hljs-keyword">const</span> timestamp = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toLocaleString();
  <span class="hljs-keyword">const</span> prefix = <span class="hljs-string">`[<span class="hljs-subst">${timestamp}</span>] [<span class="hljs-subst">${level}</span>]`</span>;

  <span class="hljs-keyword">switch</span> (level) {
    <span class="hljs-keyword">case</span> ERROR:
      <span class="hljs-built_in">console</span>.error(prefix, message, ...data);
      <span class="hljs-keyword">break</span>;
    <span class="hljs-keyword">case</span> WARN:
      <span class="hljs-built_in">console</span>.warn(prefix, message, ...data);
      <span class="hljs-keyword">break</span>;
    <span class="hljs-keyword">default</span>:
      <span class="hljs-built_in">console</span>.log(prefix, message, ...data);
  }
}
</code></pre>
<p>This is already looking great. Why not write a small environment variables validator? Run this during the initial program startup, and if something goes wrong, the application will exit with clear logs so users know if any environment variables are missing.</p>
<p>Create a new file named <code>env-validator.ts</code> in the <code>utils</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/utils/env-validator.ts</span>

<span class="hljs-keyword">import</span> { log, ERROR } <span class="hljs-keyword">from</span> <span class="hljs-string">"./logger.js"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> OPENAI_API_KEY = <span class="hljs-string">"OPENAI_API_KEY"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> DISCORD_BOT_TOKEN = <span class="hljs-string">"DISCORD_BOT_TOKEN"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> DISCORD_BOT_GUILD_ID = <span class="hljs-string">"DISCORD_BOT_GUILD_ID"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> DISCORD_BOT_CLIENT_ID = <span class="hljs-string">"DISCORD_BOT_CLIENT_ID"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> COMPOSIO_API_KEY = <span class="hljs-string">"COMPOSIO_API_KEY"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> validateEnvVars = (requiredEnvVars: <span class="hljs-built_in">string</span>[]): <span class="hljs-function"><span class="hljs-params">void</span> =&gt;</span> {
  <span class="hljs-keyword">const</span> missingVars: <span class="hljs-built_in">string</span>[] = [];

  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> envVar <span class="hljs-keyword">of</span> requiredEnvVars) {
    <span class="hljs-keyword">if</span> (!process.env[envVar]) {
      missingVars.push(envVar);
    }
  }

  <span class="hljs-keyword">if</span> (missingVars.length &gt; <span class="hljs-number">0</span>) {
    log(
      ERROR,
      <span class="hljs-string">"missing required environment variables. please create a .env file and add the following:"</span>,
    );
    missingVars.forEach(<span class="hljs-function">(<span class="hljs-params">envVar</span>) =&gt;</span> <span class="hljs-built_in">console</span>.error(<span class="hljs-string">`- <span class="hljs-subst">${envVar}</span>`</span>));
    process.exit(<span class="hljs-number">1</span>);
  }
};
</code></pre>
<p>Now, let's also define the type of data you'll be working with:</p>
<p>Create a new file named <code>types.ts</code> inside the <code>types</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/types/types.ts</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> QUESTION = <span class="hljs-string">"QUESTION"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> HELP = <span class="hljs-string">"HELP"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> SUPPORT = <span class="hljs-string">"SUPPORT"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> OTHER = <span class="hljs-string">"OTHER"</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> TOOL_CALL_REQUEST = <span class="hljs-string">"TOOL_CALL_REQUEST"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> FinalAction =
  | { <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY"</span>; content: <span class="hljs-built_in">string</span> }
  | { <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY_IN_THREAD"</span>; content: <span class="hljs-built_in">string</span> }
  | {
      <span class="hljs-keyword">type</span>: <span class="hljs-string">"CREATE_EMBED"</span>;
      title: <span class="hljs-built_in">string</span>;
      description: <span class="hljs-built_in">string</span>;
      roleToPing?: <span class="hljs-built_in">string</span>;
    };

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> MessageChoice =
  | <span class="hljs-keyword">typeof</span> SUPPORT
  | <span class="hljs-keyword">typeof</span> OTHER
  | <span class="hljs-keyword">typeof</span> TOOL_CALL_REQUEST;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> SupportTicketType = <span class="hljs-keyword">typeof</span> QUESTION | <span class="hljs-keyword">typeof</span> HELP;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> Message = {
  author: <span class="hljs-built_in">string</span>;
  content: <span class="hljs-built_in">string</span>;
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> SupportTicketQuestion = {
  description: <span class="hljs-built_in">string</span>;
  answer: <span class="hljs-built_in">string</span>;
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> SupportTicket = {
  <span class="hljs-keyword">type</span>?: SupportTicketType;
  question?: SupportTicketQuestion;
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> ToolCallRequestAction = {
  <span class="hljs-comment">// actionLog is not intended to be shown to the end-user.</span>
  <span class="hljs-comment">// This is solely for logging purpose.</span>
  actionLog: <span class="hljs-built_in">string</span>;
  status: <span class="hljs-string">"success"</span> | <span class="hljs-string">"failed"</span> | <span class="hljs-string">"acknowledged"</span>;
};
</code></pre>
<p>The types are pretty self-explanatory, but here’s a quick overview.</p>
<p><code>Message</code> holds the user's input and author. Each message can be marked as support, a tool call request, or just other, like spam or small talk.</p>
<p>Support messages are further labeled as either help or a question using <code>SupportTicketType</code>.</p>
<p>The graph returns a <code>FinalAction</code>, which can be a direct reply, a reply in a thread, or an embed. If it's <code>CREATE_EMBED</code> and has <code>roleToPing</code> set, it denotes support help, so we can ping the mod.</p>
<p>For tool-based responses, <code>ToolCallRequestAction</code> stores the status and an internal log used for debugging.</p>
<p>Now, you need one last helper function to use in your nodes to extract the response from the LLM. Create a new file named <code>helpers.ts</code> and add the following code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/utils/helpers.ts</span>

<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { AIMessage } <span class="hljs-keyword">from</span> <span class="hljs-string">"@langchain/core/messages"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">extractStringFromAIMessage</span>(<span class="hljs-params">
  message: AIMessage,
  fallback: <span class="hljs-built_in">string</span> = "No valid response generated by the LLM.",
</span>): <span class="hljs-title">string</span> </span>{
  <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> message.content === <span class="hljs-string">"string"</span>) {
    <span class="hljs-keyword">return</span> message.content;
  }

  <span class="hljs-keyword">if</span> (<span class="hljs-built_in">Array</span>.isArray(message.content)) {
    <span class="hljs-keyword">const</span> textContent = message.content
      .map(<span class="hljs-function">(<span class="hljs-params">item</span>) =&gt;</span> (<span class="hljs-keyword">typeof</span> item === <span class="hljs-string">"string"</span> ? item : <span class="hljs-string">""</span>))
      .join(<span class="hljs-string">" "</span>);
    <span class="hljs-keyword">return</span> textContent.trim() || fallback;
  }

  <span class="hljs-keyword">return</span> fallback;
}
</code></pre>
<p>You're all set for now with these helper functions in place. Now, you can start coding the logic.</p>
<h3 id="heading-implement-langgraph-workflow">Implement LangGraph Workflow</h3>
<p>Now that you have the types defined, structure your graph and connect it with some edges.</p>
<p>Create a new file named <code>graph.ts</code> inside the <code>src</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/src/graph.ts</span>

<span class="hljs-keyword">import</span> { Annotation, END, START, StateGraph } <span class="hljs-keyword">from</span> <span class="hljs-string">"@langchain/langgraph"</span>;
<span class="hljs-keyword">import</span> {
  <span class="hljs-keyword">type</span> FinalAction,
  <span class="hljs-keyword">type</span> ToolCallRequestAction,
  <span class="hljs-keyword">type</span> Message,
  <span class="hljs-keyword">type</span> MessageChoice,
  <span class="hljs-keyword">type</span> SupportTicket,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"../types/types.js"</span>;
<span class="hljs-keyword">import</span> {
  processToolCall,
  processMessage,
  processOther,
  processSupport,
  processSupportHelp,
  processSupportQuestion,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"./nodes.js"</span>;
<span class="hljs-keyword">import</span> { processMessageEdges, processSupportEdges } <span class="hljs-keyword">from</span> <span class="hljs-string">"./edges.js"</span>;

<span class="hljs-keyword">const</span> state = Annotation.Root({
  message: Annotation&lt;Message&gt;(),
  previousMessages: Annotation&lt;Message[]&gt;(),
  messageChoice: Annotation&lt;MessageChoice&gt;(),
  supportTicket: Annotation&lt;SupportTicket&gt;(),
  toolCallRequest: Annotation&lt;ToolCallRequestAction&gt;(),
  finalAction: Annotation&lt;FinalAction&gt;(),
});

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> State = <span class="hljs-keyword">typeof</span> state.State;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> Update = <span class="hljs-keyword">typeof</span> state.Update;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">initializeGraph</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> workflow = <span class="hljs-keyword">new</span> StateGraph(state);

  workflow
    .addNode(<span class="hljs-string">"process-message"</span>, processMessage)
    .addNode(<span class="hljs-string">"process-support"</span>, processSupport)
    .addNode(<span class="hljs-string">"process-other"</span>, processOther)

    .addNode(<span class="hljs-string">"process-support-question"</span>, processSupportQuestion)
    .addNode(<span class="hljs-string">"process-support-help"</span>, processSupportHelp)
    .addNode(<span class="hljs-string">"process-tool-call"</span>, processToolCall)

    <span class="hljs-comment">// Edges setup starts here....</span>
    .addEdge(START, <span class="hljs-string">"process-message"</span>)

    .addConditionalEdges(<span class="hljs-string">"process-message"</span>, processMessageEdges)
    .addConditionalEdges(<span class="hljs-string">"process-support"</span>, processSupportEdges)

    .addEdge(<span class="hljs-string">"process-other"</span>, END)
    .addEdge(<span class="hljs-string">"process-support-question"</span>, END)
    .addEdge(<span class="hljs-string">"process-support-help"</span>, END)
    .addEdge(<span class="hljs-string">"process-tool-call"</span>, END);

  <span class="hljs-keyword">const</span> graph = workflow.compile();

  <span class="hljs-comment">// To get the graph in png</span>
  <span class="hljs-comment">// getGraph() is deprecated though</span>
  <span class="hljs-comment">// Bun.write("graph/graph.png", await graph.getGraph().drawMermaidPng());</span>

  <span class="hljs-keyword">return</span> graph;
}
</code></pre>
<p>The <code>initializeGraph</code> function, as the name suggests, returns the graph you can use to execute the workflow.</p>
<p>The <code>process-message</code> node is the starting point of the graph. It takes in the user’s message, processes it, and routes it to the appropriate next node: <code>process-support</code>, <code>process-tool-call</code>, or <code>process-other</code>.</p>
<p>The <code>process-support</code> node further classifies the support message and decides whether it should go to <code>process-support-help</code> or <code>process-support-question</code>.</p>
<p>The <code>process-tool-call</code> node handles messages when the user tries to trigger some kind of tool or action.</p>
<p>The <code>process-other</code> node handles everything that doesn’t fall into the support or tool call categories. These are general or fallback responses.</p>
<p>To help you visualize how things will shape up, here’s how the graph looks with all the different nodes (yet to work on!):</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750327093884/fa8e6b4e-ca61-4900-9b3b-7b3a2863c296.png" alt="LangGraph nodes for the Discord bot workflow" class="image--center mx-auto" width="886" height="432" loading="lazy"></p>
<p>To wire everything together, you need to define edges between nodes, including conditional edges that dynamically decide the next step based on the state.</p>
<p>Create a new file named <code>edges.ts</code> inside the <code>src</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/src/edges.ts</span>

<span class="hljs-keyword">import</span> { END } <span class="hljs-keyword">from</span> <span class="hljs-string">"@langchain/langgraph"</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-keyword">type</span> State } <span class="hljs-keyword">from</span> <span class="hljs-string">"./graph.js"</span>;
<span class="hljs-keyword">import</span> { QUESTION, OTHER, SUPPORT, TOOL_CALL_REQUEST } <span class="hljs-keyword">from</span> <span class="hljs-string">"../types/types.js"</span>;
<span class="hljs-keyword">import</span> { log, WARN } <span class="hljs-keyword">from</span> <span class="hljs-string">"../utils/logger.js"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processMessageEdges = (
  state: State,
): <span class="hljs-string">"process-support"</span> | <span class="hljs-string">"process-other"</span> | <span class="hljs-string">"process-tool-call"</span> | <span class="hljs-string">"__end__"</span> =&gt; {
  <span class="hljs-keyword">if</span> (!state.messageChoice) {
    log(WARN, <span class="hljs-string">"state.messageChoice is undefined. Returning..."</span>);
    <span class="hljs-keyword">return</span> END;
  }

  <span class="hljs-keyword">switch</span> (state.messageChoice) {
    <span class="hljs-keyword">case</span> SUPPORT:
      <span class="hljs-keyword">return</span> <span class="hljs-string">"process-support"</span>;
    <span class="hljs-keyword">case</span> TOOL_CALL_REQUEST:
      <span class="hljs-keyword">return</span> <span class="hljs-string">"process-tool-call"</span>;
    <span class="hljs-keyword">case</span> OTHER:
      <span class="hljs-keyword">return</span> <span class="hljs-string">"process-other"</span>;
    <span class="hljs-keyword">default</span>:
      log(WARN, <span class="hljs-string">"unknown message choice. Returning..."</span>);
      <span class="hljs-keyword">return</span> END;
  }
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processSupportEdges = (
  state: State,
): <span class="hljs-string">"process-support-question"</span> | <span class="hljs-string">"process-support-help"</span> | <span class="hljs-string">"__end__"</span> =&gt; {
  <span class="hljs-keyword">if</span> (!state.supportTicket?.type) {
    log(WARN, <span class="hljs-string">"state.supportTicket.type is undefined. Returning..."</span>);
    <span class="hljs-keyword">return</span> END;
  }

  <span class="hljs-keyword">return</span> state.supportTicket.type === QUESTION
    ? <span class="hljs-string">"process-support-question"</span>
    : <span class="hljs-string">"process-support-help"</span>;
};
</code></pre>
<p>These are the edges that connect different nodes in your application. They direct the flow in your graph.</p>
<p>Things are really shaping up – so let’s finish the core logic by implementing all the nodes for your application.</p>
<p>Create a new file named <code>nodes.ts</code> inside the <code>src</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/src/nodes.ts</span>

<span class="hljs-keyword">import</span> { <span class="hljs-keyword">type</span> State, <span class="hljs-keyword">type</span> Update } <span class="hljs-keyword">from</span> <span class="hljs-string">"./graph.js"</span>;
<span class="hljs-keyword">import</span> { ChatOpenAI } <span class="hljs-keyword">from</span> <span class="hljs-string">"@langchain/openai"</span>;
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">"zod"</span>;
<span class="hljs-keyword">import</span> {
  HELP,
  TOOL_CALL_REQUEST,
  OTHER,
  QUESTION,
  SUPPORT,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"../types/types.js"</span>;
<span class="hljs-keyword">import</span> { extractStringFromAIMessage } <span class="hljs-keyword">from</span> <span class="hljs-string">"../utils/helpers.js"</span>;
<span class="hljs-keyword">import</span> { OpenAIToolSet } <span class="hljs-keyword">from</span> <span class="hljs-string">"composio-core"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { ChatCompletionMessageToolCall } <span class="hljs-keyword">from</span> <span class="hljs-string">"openai/resources/chat/completions.mjs"</span>;
<span class="hljs-keyword">import</span> { v4 <span class="hljs-keyword">as</span> uuidv4 } <span class="hljs-keyword">from</span> <span class="hljs-string">"uuid"</span>;
<span class="hljs-keyword">import</span> { DEBUG, ERROR, INFO, log, WARN } <span class="hljs-keyword">from</span> <span class="hljs-string">"../utils/logger.js"</span>;
<span class="hljs-keyword">import</span> {
  SystemMessage,
  HumanMessage,
  ToolMessage,
  BaseMessage,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"@langchain/core/messages"</span>;

<span class="hljs-comment">// feel free to use any model. Here I'm going with gpt-4o-mini</span>
<span class="hljs-keyword">const</span> model = <span class="hljs-string">"gpt-4o-mini"</span>;

<span class="hljs-keyword">const</span> toolset = <span class="hljs-keyword">new</span> OpenAIToolSet();
<span class="hljs-keyword">const</span> llm = <span class="hljs-keyword">new</span> ChatOpenAI({
  model,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: <span class="hljs-number">0</span>,
});

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processMessage = <span class="hljs-keyword">async</span> (state: State): <span class="hljs-built_in">Promise</span>&lt;Update&gt; =&gt; {
  log(DEBUG, <span class="hljs-string">"message in process message:"</span>, state.message);

  <span class="hljs-keyword">const</span> llm = <span class="hljs-keyword">new</span> ChatOpenAI({
    model,
    apiKey: process.env.OPENAI_API_KEY,
    temperature: <span class="hljs-number">0</span>,
  });

  <span class="hljs-keyword">const</span> structuredLlm = llm.withStructuredOutput(
    z.object({
      <span class="hljs-keyword">type</span>: z.enum([SUPPORT, OTHER, TOOL_CALL_REQUEST]).describe(<span class="hljs-string">`
Categorize the user's message:
- <span class="hljs-subst">${SUPPORT}</span>: Technical support, help with problems, or questions about AI.
- <span class="hljs-subst">${TOOL_CALL_REQUEST}</span>: User asks the bot to perform tool action (e.g., "send an email", "summarize chat", "summarize google sheets").
- <span class="hljs-subst">${OTHER}</span>: General conversation, spam, or off-topic messages.
`</span>),
    }),
  );

  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> structuredLlm.invoke([
    [
      <span class="hljs-string">"system"</span>,
      <span class="hljs-string">`You are an expert message analyzer AI. You need to categorize the message into
one of these categories:

- <span class="hljs-subst">${SUPPORT}</span>: If the message asks for technical support, help with a problem, or questions about AIs and LLMs.
- <span class="hljs-subst">${TOOL_CALL_REQUEST}</span>: If the message is a direct command or request for the bot to perform an action using external tools/services. Examples: "Summarize a document or Google Sheet", "Summarize the last hour of chat", "Send an email to devteam about this bug", "Create a Trello card for this feature request". Prioritize this if the user is asking the bot to *do* something beyond just answering.
- <span class="hljs-subst">${OTHER}</span>: For general chit-chat, spam, off-topic messages, or anything not fitting <span class="hljs-subst">${SUPPORT}</span> or <span class="hljs-subst">${TOOL_CALL_REQUEST}</span>.
`</span>,
    ],
    [<span class="hljs-string">"human"</span>, state.message.content],
  ]);

  <span class="hljs-keyword">return</span> {
    messageChoice: res.type,
  };
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processSupport = <span class="hljs-keyword">async</span> (state: State): <span class="hljs-built_in">Promise</span>&lt;Update&gt; =&gt; {
  log(DEBUG, <span class="hljs-string">"message in support:"</span>, state.message);

  <span class="hljs-keyword">const</span> llm = <span class="hljs-keyword">new</span> ChatOpenAI({
    model,
    apiKey: process.env.OPENAI_API_KEY,
    temperature: <span class="hljs-number">0</span>,
  });

  <span class="hljs-keyword">const</span> structuredLlm = llm.withStructuredOutput(
    z.object({
      <span class="hljs-keyword">type</span>: z.enum([QUESTION, HELP]).describe(<span class="hljs-string">`
Type of support needed:
- <span class="hljs-subst">${QUESTION}</span>: User asks a specific question seeking information or an answer.
- <span class="hljs-subst">${HELP}</span>: User needs broader assistance, guidance, or reports an issue requiring intervention/troubleshooting.
`</span>),
    }),
  );

  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> structuredLlm.invoke([
    [
      <span class="hljs-string">"system"</span>,
      <span class="hljs-string">`
You are a support ticket analyzer. Given a support message, categorize it as <span class="hljs-subst">${QUESTION}</span> or <span class="hljs-subst">${HELP}</span>.
- <span class="hljs-subst">${QUESTION}</span>: For specific questions.
- <span class="hljs-subst">${HELP}</span>: For requests for assistance, troubleshooting, or problem reports.
`</span>,
    ],
    [<span class="hljs-string">"human"</span>, state.message.content],
  ]);

  <span class="hljs-keyword">return</span> {
    supportTicket: {
      ...state.supportTicket,
      <span class="hljs-keyword">type</span>: res.type,
    },
  };
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processSupportHelp = <span class="hljs-keyword">async</span> (state: State): <span class="hljs-built_in">Promise</span>&lt;Update&gt; =&gt; {
  log(DEBUG, <span class="hljs-string">"message in support help:"</span>, state.message);

  <span class="hljs-keyword">return</span> {
    supportTicket: {
      ...state.supportTicket,
    },
    finalAction: {
      <span class="hljs-keyword">type</span>: <span class="hljs-string">"CREATE_EMBED"</span>,
      title: <span class="hljs-string">"🚨 Help Needed!"</span>,
      description: <span class="hljs-string">`A new request for help has been raised by **@<span class="hljs-subst">${state.message.author}</span>**.\n\n**Query:**\n&gt; <span class="hljs-subst">${state.message.content}</span>`</span>,
      roleToPing: process.env.DISCORD_SUPPORT_MOD_ID,
    },
  };
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processSupportQuestion = <span class="hljs-keyword">async</span> (state: State): <span class="hljs-built_in">Promise</span>&lt;Update&gt; =&gt; {
  log(DEBUG, <span class="hljs-string">"message in support question category:"</span>, state.message);

  <span class="hljs-keyword">const</span> llm = <span class="hljs-keyword">new</span> ChatOpenAI({
    model,
    apiKey: process.env.OPENAI_API_KEY,
    temperature: <span class="hljs-number">0</span>,
  });

  <span class="hljs-keyword">const</span> systemPrompt = <span class="hljs-string">`
You are a helpful AI assistant specializing in AI, and LLMs. Answer
the user's question concisely and accurately based on general knowledge in
these areas. If the question is outside this scope (e.g., personal advice,
non-technical topics), politely state you cannot answer. User's question:
`</span>;

  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> llm.invoke([
    [<span class="hljs-string">"system"</span>, systemPrompt],
    [<span class="hljs-string">"human"</span>, state.message.content],
  ]);

  <span class="hljs-keyword">const</span> llmResponse = extractStringFromAIMessage(res);
  <span class="hljs-keyword">return</span> {
    supportTicket: {
      ...state.supportTicket,
      question: {
        description: state.message.content,
        answer: llmResponse,
      },
    },
    finalAction: {
      <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY"</span>,
      content: llmResponse,
    },
  };
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processOther = <span class="hljs-keyword">async</span> (state: State): <span class="hljs-built_in">Promise</span>&lt;Update&gt; =&gt; {
  log(DEBUG, <span class="hljs-string">"message in other category:"</span>, state.message);

  <span class="hljs-keyword">const</span> response =
    <span class="hljs-string">"This seems to be a general message. I'm here to help with technical support or perform specific actions if you ask. How can I assist you with those?"</span>;

  <span class="hljs-keyword">return</span> {
    finalAction: {
      <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY_IN_THREAD"</span>,
      content: response,
    },
  };
};
</code></pre>
<p>There’s not much to explain for these nodes. Each node in the flow functions as a message classifier. It spins up a Chat LLM instance and uses structured output to ensure the model returns a specific label from a predefined set like <code>QUESTION</code> or <code>HELP</code> for support messages. The system prompt clearly defines what each label means, and your user message is passed in for classification.</p>
<p>You’re almost there. But there’s one piece missing. Can you spot it?</p>
<p>The <code>process-tool-call</code> node that’s supposed to handle the workflow when the user asks to use a tool. This is a big piece of the workflow.</p>
<p>It’s a bit longer, so I’ll explain it separately.</p>
<p>Modify the above <code>nodes.ts</code> file to add the missing node:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/src/nodes.ts</span>

<span class="hljs-comment">// Rest of the code...</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> processToolCall = <span class="hljs-keyword">async</span> (state: State): <span class="hljs-built_in">Promise</span>&lt;Update&gt; =&gt; {
  log(DEBUG, <span class="hljs-string">"message in tool call request category:"</span>, state.message);

  <span class="hljs-keyword">const</span> structuredOutputType = z.object({
    service: z
      .string()
      .describe(<span class="hljs-string">"The target service (e.g., 'email', 'discord')."</span>),
    task: z
      .string()
      .describe(
        <span class="hljs-string">"A concise description of the task (e.g., 'send email to X', 'summarize recent chat', 'create task Y')."</span>,
      ),
    details: z
      .string()
      .optional()
      .describe(
        <span class="hljs-string">"Any specific details or parameters extracted from the message relevant to the task."</span>,
      ),
  });

  <span class="hljs-keyword">const</span> structuredLlm = llm.withStructuredOutput(structuredOutputType);

  <span class="hljs-keyword">let</span> parsedActionDetails: z.infer&lt;<span class="hljs-keyword">typeof</span> structuredOutputType&gt; = {
    service: <span class="hljs-string">"unknown"</span>,
    task: <span class="hljs-string">"perform a requested action"</span>,
  };

  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> structuredLlm.invoke([
      [
        <span class="hljs-string">"system"</span>,
        <span class="hljs-string">`Parse the user's request to identify an action. Extract the target service, a description of the task, and any relevant details or parameters.
      Examples:
      - "Remind me to check emails at 5 PM": service: calendar/reminder, task: set reminder, details: check emails at 5 PM
      - "Send a summary of this conversation to #general channel": service: discord, task: send summary to channel, details: channel #general
      - "Create a bug report for 'login fails on mobile'": service: project_manager, task: create bug report, details: title 'login fails on mobile'`</span>,
      ],
      [<span class="hljs-string">"human"</span>, state.message.content],
    ]);

    parsedActionDetails = res;
    log(INFO, <span class="hljs-string">"initial parsing action details:"</span>, parsedActionDetails);
  } <span class="hljs-keyword">catch</span> (error) {
    log(ERROR, <span class="hljs-string">"initial parsing error:"</span>, error);
    <span class="hljs-keyword">return</span> {
      toolCallRequest: {
        actionLog: <span class="hljs-string">`Failed to parse user request: <span class="hljs-subst">${state.message.content}</span>`</span>,
        status: <span class="hljs-string">"failed"</span>,
      },
      finalAction: {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY_IN_THREAD"</span>,
        content:
          <span class="hljs-string">"I'm sorry, I had trouble understanding that action. Could you please rephrase it?"</span>,
      },
    };
  }

  <span class="hljs-keyword">try</span> {
    log(INFO, <span class="hljs-string">"fetching composio tools"</span>);
    <span class="hljs-keyword">const</span> tools = <span class="hljs-keyword">await</span> toolset.getTools({
      apps: [<span class="hljs-string">"GOOGLESHEETS"</span>],
    });

    log(INFO, <span class="hljs-string">`fetched <span class="hljs-subst">${tools.length}</span> tools. Errors if &gt; 128 for OpenAI:`</span>);

    <span class="hljs-keyword">if</span> (tools.length === <span class="hljs-number">0</span>) {
      log(WARN, <span class="hljs-string">"no tools fetched from Composio. skipping..."</span>);
      <span class="hljs-keyword">return</span> {
        toolCallRequest: {
          actionLog: <span class="hljs-string">`Service: <span class="hljs-subst">${parsedActionDetails.service}</span>, Task: <span class="hljs-subst">${parsedActionDetails.task}</span>. No composio tools found`</span>,
          status: <span class="hljs-string">"failed"</span>,
        },
        finalAction: {
          <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY_IN_THREAD"</span>,
          content: <span class="hljs-string">"Couldn't find any tools to perform your action."</span>,
        },
      };
    }

    log(DEBUG, <span class="hljs-string">"starting iterative tool execution loop"</span>);

    <span class="hljs-keyword">const</span> conversationHistory: BaseMessage[] = [
      <span class="hljs-keyword">new</span> SystemMessage(
        <span class="hljs-string">"You are a helpful assistant that performs tool calls. Your task is to understand the user's request and use the available tools to fulfill the request completely. You can use multiple tools in sequence to accomplish complex tasks. Always provide a brief, conversational summary of what you accomplished after using tools."</span>,
      ),
      <span class="hljs-keyword">new</span> HumanMessage(state.message.content),
    ];

    <span class="hljs-keyword">let</span> totalToolsUsed = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">let</span> finalResponse: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span> = <span class="hljs-literal">null</span>;

    <span class="hljs-keyword">const</span> maxIterations = <span class="hljs-number">5</span>;
    <span class="hljs-keyword">let</span> iteration = <span class="hljs-number">0</span>;

    <span class="hljs-keyword">while</span> (iteration &lt; maxIterations) {
      iteration++;
      log(
        DEBUG,
        <span class="hljs-string">`Iteration <span class="hljs-subst">${iteration}</span>: calling LLM with <span class="hljs-subst">${tools.length}</span> tools`</span>,
      );

      <span class="hljs-keyword">const</span> llmResponse = <span class="hljs-keyword">await</span> llm.invoke(conversationHistory, {
        tools: tools,
      });

      log(DEBUG, <span class="hljs-string">`Iteration <span class="hljs-subst">${iteration}</span> LLM response:`</span>, llmResponse);

      <span class="hljs-keyword">const</span> toolCalls = llmResponse.tool_calls;

      <span class="hljs-keyword">if</span> ((!toolCalls || toolCalls.length === <span class="hljs-number">0</span>) &amp;&amp; llmResponse.content) {
        finalResponse =
          <span class="hljs-keyword">typeof</span> llmResponse.content === <span class="hljs-string">"string"</span>
            ? llmResponse.content
            : <span class="hljs-built_in">JSON</span>.stringify(llmResponse.content);
        log(
          INFO,
          <span class="hljs-string">`Final response received after <span class="hljs-subst">${iteration}</span> iterations:`</span>,
          finalResponse,
        );
        <span class="hljs-keyword">break</span>;
      }

      <span class="hljs-keyword">if</span> (toolCalls &amp;&amp; toolCalls.length &gt; <span class="hljs-number">0</span>) {
        log(
          INFO,
          <span class="hljs-string">`Iteration <span class="hljs-subst">${iteration}</span>: executing <span class="hljs-subst">${toolCalls.length}</span> tool(s)`</span>,
        );
        totalToolsUsed += toolCalls.length;

        conversationHistory.push(llmResponse);

        <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> toolCall <span class="hljs-keyword">of</span> toolCalls) {
          log(
            INFO,
            <span class="hljs-string">`Executing tool: <span class="hljs-subst">${toolCall.name}</span> with args:`</span>,
            toolCall.args,
          );

          <span class="hljs-keyword">const</span> composioCompatibleToolCall: ChatCompletionMessageToolCall = {
            id: toolCall.id || uuidv4(),
            <span class="hljs-keyword">type</span>: <span class="hljs-string">"function"</span>,
            <span class="hljs-function"><span class="hljs-keyword">function</span>: </span>{
              name: toolCall.name,
              <span class="hljs-built_in">arguments</span>: <span class="hljs-built_in">JSON</span>.stringify(toolCall.args),
            },
          };

          <span class="hljs-keyword">let</span> toolOutputContent: <span class="hljs-built_in">string</span>;
          <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">const</span> executionResult = <span class="hljs-keyword">await</span> toolset.executeToolCall(
              composioCompatibleToolCall,
            );
            log(
              INFO,
              <span class="hljs-string">`Tool <span class="hljs-subst">${toolCall.name}</span> execution result:`</span>,
              executionResult,
            );
            toolOutputContent = <span class="hljs-built_in">JSON</span>.stringify(executionResult);
          } <span class="hljs-keyword">catch</span> (toolError) {
            log(ERROR, <span class="hljs-string">`Tool <span class="hljs-subst">${toolCall.name}</span> execution error:`</span>, toolError);
            <span class="hljs-keyword">const</span> errorMessage =
              toolError <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span>
                ? toolError.message
                : <span class="hljs-built_in">String</span>(toolError);

            toolOutputContent = <span class="hljs-string">`Error: <span class="hljs-subst">${errorMessage}</span>`</span>;
          }

          conversationHistory.push(
            <span class="hljs-keyword">new</span> ToolMessage({
              content: toolOutputContent,
              tool_call_id: toolCall.id || uuidv4(),
            }),
          );
        }

        <span class="hljs-keyword">continue</span>;
      }

      log(
        WARN,
        <span class="hljs-string">`Iteration <span class="hljs-subst">${iteration}</span>: LLM provided no tool calls or content`</span>,
      );
      <span class="hljs-keyword">break</span>;
    }

    <span class="hljs-keyword">let</span> userFriendlyResponse: <span class="hljs-built_in">string</span>;

    <span class="hljs-keyword">if</span> (totalToolsUsed &gt; <span class="hljs-number">0</span>) {
      log(DEBUG, <span class="hljs-string">"Generating user-friendly summary using LLM"</span>);

      <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">const</span> summaryResponse = <span class="hljs-keyword">await</span> llm.invoke([
          <span class="hljs-keyword">new</span> SystemMessage(
            <span class="hljs-string">"You are tasked with creating a brief, friendly summary for a Discord user about what actions were just completed. Keep it conversational, under 2-3 sentences, and focus on what was accomplished rather than technical details. Start with phrases like 'Done!', 'Successfully completed', 'All set!', etc."</span>,
          ),
          <span class="hljs-keyword">new</span> HumanMessage(
            <span class="hljs-string">`The user requested: "<span class="hljs-subst">${state.message.content}</span>"

I used <span class="hljs-subst">${totalToolsUsed}</span> tools across <span class="hljs-subst">${iteration}</span> iterations to complete their request. <span class="hljs-subst">${finalResponse ? <span class="hljs-string">`My final response was: <span class="hljs-subst">${finalResponse}</span>`</span> : <span class="hljs-string">"The task was completed successfully."</span>}</span>

Generate a brief, friendly summary of what was accomplished.`</span>,
          ),
        ]);

        userFriendlyResponse =
          <span class="hljs-keyword">typeof</span> summaryResponse.content === <span class="hljs-string">"string"</span>
            ? summaryResponse.content
            : <span class="hljs-string">`Done! I've completed your request using <span class="hljs-subst">${totalToolsUsed}</span> action<span class="hljs-subst">${totalToolsUsed &gt; <span class="hljs-number">1</span> ? <span class="hljs-string">"s"</span> : <span class="hljs-string">""</span>}</span>.`</span>;

        log(INFO, <span class="hljs-string">"Generated user-friendly summary:"</span>, userFriendlyResponse);
      } <span class="hljs-keyword">catch</span> (summaryError) {
        log(ERROR, <span class="hljs-string">"Failed to generate summary:"</span>, summaryError);
        userFriendlyResponse = <span class="hljs-string">`All set! I've completed your request using <span class="hljs-subst">${totalToolsUsed}</span> action<span class="hljs-subst">${totalToolsUsed &gt; <span class="hljs-number">1</span> ? <span class="hljs-string">"s"</span> : <span class="hljs-string">""</span>}</span>.`</span>;
      }
    } <span class="hljs-keyword">else</span> {
      userFriendlyResponse =
        finalResponse ||
        <span class="hljs-string">`I understood your request about '<span class="hljs-subst">${parsedActionDetails.task}</span>' but couldn't find the right tools to complete it.`</span>;
    }

    <span class="hljs-keyword">const</span> actionLog = <span class="hljs-string">`Service: <span class="hljs-subst">${parsedActionDetails.service}</span>, Task: <span class="hljs-subst">${parsedActionDetails.task}</span>. Used <span class="hljs-subst">${totalToolsUsed}</span> tools across <span class="hljs-subst">${iteration}</span> iterations.`</span>;

    <span class="hljs-keyword">return</span> {
      toolCallRequest: {
        actionLog,
        status: totalToolsUsed &gt; <span class="hljs-number">0</span> ? <span class="hljs-string">"success"</span> : <span class="hljs-string">"acknowledged"</span>,
      },
      finalAction: {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY_IN_THREAD"</span>,
        content: userFriendlyResponse,
      },
    };
  } <span class="hljs-keyword">catch</span> (error) {
    log(ERROR, <span class="hljs-string">"processing tool call with Composio:"</span>, error);
    <span class="hljs-keyword">const</span> errorMessage = error <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span> ? error.message : <span class="hljs-built_in">String</span>(error);

    <span class="hljs-keyword">return</span> {
      toolCallRequest: {
        actionLog: <span class="hljs-string">`Error during tool call (Service: <span class="hljs-subst">${parsedActionDetails.service}</span>, Task: <span class="hljs-subst">${parsedActionDetails.task}</span>). Error: <span class="hljs-subst">${errorMessage}</span>`</span>,
        status: <span class="hljs-string">"failed"</span>,
      },
      finalAction: {
        <span class="hljs-keyword">type</span>: <span class="hljs-string">"REPLY_IN_THREAD"</span>,
        content: <span class="hljs-string">"Sorry, I encountered an error while processing your request."</span>,
      },
    };
  }
};
</code></pre>
<p>The part up until the first try-catch block is the same. Up until then, you're figuring out the tool the user is trying to call. Now comes the juicy part: actually handling tool calls.</p>
<p>At this point, you need to fetch the tools from Composio. Here, I’m just passing in Google Sheets as the option for demo purposes, but you could use literally anything once you authenticate yourself as shown above.</p>
<p>After fetching the tools, you enter a loop where the LLM can use them. It reviews the conversation history and decides which tools to call. You execute these calls, feed the results back, and repeat for up to 5 iterations or until the LLM gives a final answer.</p>
<p>This loop runs up to 5 times as a safeguard so the LLM doesn’t get stuck in an endless back-and-forth.</p>
<p>If tools were used, you ask the LLM to write a friendly summary for the user instead of dumping the raw JSON response. If no tools worked or none matched, just let the user know you couldn’t perform the action.</p>
<p>Now with that, you’re done with the difficult part (I mean, it was pretty easy though, right?). From here on, you just need to set up and work with the Discord API using Discord.js.</p>
<h3 id="heading-set-up-discordjs-client">Set Up Discord.js Client</h3>
<p>In this application, you’re using slash commands. To use slash commands in Discord, you need to register them first. You can do this manually, but why not automate it as well? 😉</p>
<p>Create a new file named <code>slash-deploy.ts</code> inside the <code>utils</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/utils/slash-deploy.ts</span>

<span class="hljs-keyword">import</span> { REST, Routes } <span class="hljs-keyword">from</span> <span class="hljs-string">"discord.js"</span>;
<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">"dotenv"</span>;
<span class="hljs-keyword">import</span> { log, INFO, ERROR } <span class="hljs-keyword">from</span> <span class="hljs-string">"./logger.js"</span>;
<span class="hljs-keyword">import</span> {
  DISCORD_BOT_TOKEN,
  DISCORD_BOT_GUILD_ID,
  OPENAI_API_KEY,
  DISCORD_BOT_CLIENT_ID,
  validateEnvVars,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"./env-validator.js"</span>;

dotenv.config();

<span class="hljs-keyword">const</span> requiredEnvVars = [
  DISCORD_BOT_TOKEN,
  DISCORD_BOT_GUILD_ID,
  DISCORD_BOT_CLIENT_ID,
  OPENAI_API_KEY,
];
validateEnvVars(requiredEnvVars);

<span class="hljs-keyword">const</span> commands = [
  {
    name: <span class="hljs-string">"ask"</span>,
    description: <span class="hljs-string">"Ask the AI assistant a question or give it a command."</span>,
    options: [
      {
        name: <span class="hljs-string">"prompt"</span>,
        <span class="hljs-keyword">type</span>: <span class="hljs-number">3</span>,
        description: <span class="hljs-string">"Your question or command for the bot"</span>,
        required: <span class="hljs-literal">true</span>,
      },
    ],
  },
];

<span class="hljs-keyword">const</span> rest = <span class="hljs-keyword">new</span> REST({ version: <span class="hljs-string">"10"</span> }).setToken(
  process.env.DISCORD_BOT_TOKEN!,
);

(<span class="hljs-keyword">async</span> () =&gt; {
  <span class="hljs-keyword">try</span> {
    log(INFO, <span class="hljs-string">"deploying slash(/) commands"</span>);
    <span class="hljs-keyword">await</span> rest.put(
      Routes.applicationGuildCommands(
        process.env.DISCORD_BOT_CLIENT_ID!,
        process.env.DISCORD_BOT_GUILD_ID!,
      ),
      {
        body: commands,
      },
    );

    log(INFO, <span class="hljs-string">"slash(/) commands deployed"</span>);
  } <span class="hljs-keyword">catch</span> (error) {
    log(ERROR, <span class="hljs-string">"deploying slash(/) commands:"</span>, error);
  }
})();
</code></pre>
<p>See your <code>validateEnvVars</code> function in action? Here, you’re specifying the environment variables that must be set before running the program. If any are missing and you try to run the program, you’ll get an error.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750340614800/ce0b37bc-647c-4b94-9099-2e396b0ffa93.png" alt="Command failed output for deploying slash command to Discord" class="image--center mx-auto" width="1221" height="191" loading="lazy"></p>
<p>The way you deploy the slash commands to Discord is using the <code>REST</code> API provided by <code>discord.js</code>, specifically by calling <code>rest.put</code> with your command data and target guild.</p>
<p>Now, simply run the <code>commands:deploy</code> bun script and you should have <code>/ask</code> registered as a slash command in your Discord.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750340646555/2d5b22df-cd43-4e54-b985-b64576831316.png" alt="2d5b22df-cd43-4e54-b985-b64576831316" class="image--center mx-auto" width="1080" height="165" loading="lazy"></p>
<p>At this point, you should see the <code>/ask</code> slash command available in your server. All that’s left is to create the <code>index.ts</code> file, which will be the entry point to your Discord bot.</p>
<p>Create a new file named <code>index.ts</code> inside the <code>src</code> directory and add the following lines of code:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// 👇 discord-bot-langgraph/src/index.ts</span>

<span class="hljs-keyword">import</span> dotenv <span class="hljs-keyword">from</span> <span class="hljs-string">"dotenv"</span>;
<span class="hljs-keyword">import</span> {
  Client,
  Events,
  GatewayIntentBits,
  EmbedBuilder,
  <span class="hljs-keyword">type</span> Interaction,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"discord.js"</span>;
<span class="hljs-keyword">import</span> { initializeGraph } <span class="hljs-keyword">from</span> <span class="hljs-string">"./graph.js"</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-keyword">type</span> Message <span class="hljs-keyword">as</span> ChatMessage } <span class="hljs-keyword">from</span> <span class="hljs-string">"../types/types.js"</span>;
<span class="hljs-keyword">import</span> { ERROR, INFO, log } <span class="hljs-keyword">from</span> <span class="hljs-string">"../utils/logger.js"</span>;
<span class="hljs-keyword">import</span> {
  DISCORD_BOT_TOKEN,
  DISCORD_BOT_GUILD_ID,
  OPENAI_API_KEY,
  validateEnvVars,
  DISCORD_BOT_CLIENT_ID,
  COMPOSIO_API_KEY,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"../utils/env-validator.js"</span>;

dotenv.config();

<span class="hljs-keyword">const</span> requiredEnvVars = [
  DISCORD_BOT_CLIENT_ID,
  DISCORD_BOT_TOKEN,
  DISCORD_BOT_GUILD_ID,

  OPENAI_API_KEY,

  COMPOSIO_API_KEY,
];
validateEnvVars(requiredEnvVars);

<span class="hljs-keyword">const</span> graph = initializeGraph();

<span class="hljs-keyword">const</span> client = <span class="hljs-keyword">new</span> Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
});

<span class="hljs-comment">// use a map to store history per channel to make it work properly with all the</span>
<span class="hljs-comment">// channels and not for one specific channel.</span>
<span class="hljs-keyword">const</span> channelHistories = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>&lt;<span class="hljs-built_in">string</span>, ChatMessage[]&gt;();

client.on(Events.ClientReady, <span class="hljs-keyword">async</span> (readyClient) =&gt; {
  log(INFO, <span class="hljs-string">`logged in as <span class="hljs-subst">${readyClient.user.tag}</span>. ready to process commands!`</span>);
});

client.on(Events.InteractionCreate, <span class="hljs-keyword">async</span> (interaction: Interaction) =&gt; {
  <span class="hljs-keyword">if</span> (!interaction.isChatInputCommand()) <span class="hljs-keyword">return</span>;
  <span class="hljs-keyword">if</span> (interaction.commandName !== <span class="hljs-string">"ask"</span>) <span class="hljs-keyword">return</span>;

  <span class="hljs-keyword">const</span> userPrompt = interaction.options.getString(<span class="hljs-string">"prompt"</span>, <span class="hljs-literal">true</span>);
  <span class="hljs-keyword">const</span> user = interaction.user;
  <span class="hljs-keyword">const</span> channelId = interaction.channelId;

  <span class="hljs-keyword">if</span> (!channelHistories.has(channelId)) channelHistories.set(channelId, []);

  <span class="hljs-keyword">const</span> messageHistory = channelHistories.get(channelId)!;

  <span class="hljs-keyword">const</span> currentUserMessage: ChatMessage = {
    author: user.username,
    content: userPrompt,
  };

  <span class="hljs-keyword">const</span> graphInput = {
    message: currentUserMessage,
    previousMessages: [...messageHistory],
  };

  messageHistory.push(currentUserMessage);
  <span class="hljs-keyword">if</span> (messageHistory.length &gt; <span class="hljs-number">20</span>) messageHistory.shift();

  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">await</span> interaction.reply({
      content: <span class="hljs-string">"Hmm... processing your request! 🐀"</span>,
    });

    <span class="hljs-keyword">const</span> finalState = <span class="hljs-keyword">await</span> graph.invoke(graphInput);

    <span class="hljs-keyword">if</span> (!finalState.finalAction) {
      log(ERROR, <span class="hljs-string">"no final action found"</span>);
      <span class="hljs-keyword">await</span> interaction.editReply({
        content: <span class="hljs-string">"I'm sorry, I couldn't process your request."</span>,
      });
      <span class="hljs-keyword">return</span>;
    }

    <span class="hljs-keyword">const</span> userPing = <span class="hljs-string">`&lt;@<span class="hljs-subst">${user.id}</span>&gt;`</span>;
    <span class="hljs-keyword">const</span> action = finalState.finalAction;

    <span class="hljs-keyword">const</span> quotedPrompt = <span class="hljs-string">`🗣️ "<span class="hljs-subst">${userPrompt}</span>"`</span>;

    <span class="hljs-keyword">switch</span> (action.type) {
      <span class="hljs-keyword">case</span> <span class="hljs-string">"REPLY"</span>:
        <span class="hljs-keyword">await</span> interaction.editReply({
          content: <span class="hljs-string">`<span class="hljs-subst">${userPing}</span>\n\n<span class="hljs-subst">${quotedPrompt}</span>\n\n<span class="hljs-subst">${action.content}</span>`</span>,
        });
        <span class="hljs-keyword">break</span>;

      <span class="hljs-keyword">case</span> <span class="hljs-string">"REPLY_IN_THREAD"</span>:
        <span class="hljs-keyword">if</span> (!interaction.channel || !(<span class="hljs-string">"threads"</span> <span class="hljs-keyword">in</span> interaction.channel)) {
          <span class="hljs-keyword">await</span> interaction.editReply({
            content: <span class="hljs-string">"Cannot create a thread in this channel"</span>,
          });
          <span class="hljs-keyword">return</span>;
        }

        <span class="hljs-keyword">try</span> {
          <span class="hljs-keyword">const</span> thread = <span class="hljs-keyword">await</span> interaction.channel.threads.create({
            name: <span class="hljs-string">`Action: <span class="hljs-subst">${userPrompt.substring(<span class="hljs-number">0</span>, <span class="hljs-number">50</span>)}</span>...`</span>,
            autoArchiveDuration: <span class="hljs-number">60</span>,
          });

          <span class="hljs-keyword">await</span> thread.send(
            <span class="hljs-string">`<span class="hljs-subst">${userPing}</span>\n\n<span class="hljs-subst">${quotedPrompt}</span>\n\n<span class="hljs-subst">${action.content}</span>`</span>,
          );
          <span class="hljs-keyword">await</span> interaction.editReply({
            content: <span class="hljs-string">`I've created a thread for you: <span class="hljs-subst">${thread.url}</span>`</span>,
          });
        } <span class="hljs-keyword">catch</span> (threadError) {
          log(ERROR, <span class="hljs-string">"failed to create or reply in thread:"</span>, threadError);
          <span class="hljs-keyword">await</span> interaction.editReply({
            content: <span class="hljs-string">`<span class="hljs-subst">${userPing}</span>\n\n<span class="hljs-subst">${quotedPrompt}</span>\n\nI tried to create a thread but failed. Here is your response:\n\n<span class="hljs-subst">${action.content}</span>`</span>,
          });
        }
        <span class="hljs-keyword">break</span>;

      <span class="hljs-keyword">case</span> <span class="hljs-string">"CREATE_EMBED"</span>: {
        <span class="hljs-keyword">const</span> embed = <span class="hljs-keyword">new</span> EmbedBuilder()
          .setColor(<span class="hljs-number">0xffa500</span>)
          .setTitle(action.title)
          .setDescription(action.description)
          .setTimestamp()
          .setFooter({ text: <span class="hljs-string">"Support System"</span> });

        <span class="hljs-keyword">const</span> rolePing = action.roleToPing ? <span class="hljs-string">`&lt;@<span class="hljs-subst">${action.roleToPing}</span>&gt;`</span> : <span class="hljs-string">""</span>;

        <span class="hljs-keyword">await</span> interaction.editReply({
          content: <span class="hljs-string">`<span class="hljs-subst">${userPing}</span> <span class="hljs-subst">${rolePing}</span>`</span>,
          embeds: [embed],
        });
        <span class="hljs-keyword">break</span>;
      }
    }
  } <span class="hljs-keyword">catch</span> (error) {
    log(ERROR, <span class="hljs-string">"generating AI response or processing graph:"</span>, error);
    <span class="hljs-keyword">const</span> errorMessage =
      <span class="hljs-string">"sorry, I encountered an error while processing your request."</span>;
    <span class="hljs-keyword">if</span> (interaction.replied || interaction.deferred) {
      <span class="hljs-keyword">await</span> interaction.followUp({ content: errorMessage, ephemeral: <span class="hljs-literal">true</span> });
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-keyword">await</span> interaction.reply({ content: errorMessage, ephemeral: <span class="hljs-literal">true</span> });
    }
  }
});

<span class="hljs-keyword">const</span> token = process.env.DISCORD_BOT_TOKEN!;
client.login(token);
</code></pre>
<p>At the core of our bot is the <code>Client</code> object from <code>discord.js</code>. This represents your bot and handles everything from connecting to Discord’s API to listening for events like user messages or interactions.</p>
<p>What’s with that intent? Discord uses intents as a way for bots to declare what kind of data they want access to. In our case:</p>
<ul>
<li><p><code>Guilds</code> lets the bot connect to servers</p>
</li>
<li><p><code>GuildMessages</code> allows it to see messages</p>
</li>
<li><p><code>MessageContent</code> gives access to the actual content of messages</p>
</li>
</ul>
<p>These are quite standard, and there are many more based on different use cases. You can always check them all out <a target="_blank" href="https://discordjs.guide/popular-topics/intents.html#privileged-intents">here</a>.</p>
<p>You also keep a <code>Map</code> to store per-channel message history so the bot can respond with context across multiple channels:</p>
<pre><code class="lang-ts"><span class="hljs-keyword">const</span> channelHistories = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>&lt;<span class="hljs-built_in">string</span>, ChatMessage[]&gt;();
</code></pre>
<p>Discord.js provides access to a few events that you can listen to. When you work with slash commands, it registers an <code>Events.InteractionCreate</code>, which is what you’re listening to.</p>
<p>With every <code>/ask</code> command, you take the user's prompt and any previous messages. If <code>channelHistories</code> does not have a key with that specific channelId, meaning it's being used for the first time, you initialize it with an empty array and feed them into the AI state.</p>
<pre><code class="lang-ts"><span class="hljs-keyword">const</span> finalState = <span class="hljs-keyword">await</span> graph.invoke({
  message: currentUserMessage,
  previousMessages: [...messageHistory],
});
</code></pre>
<p>Depending on what the graph <code>finalAction.type</code> returns, you either:</p>
<ul>
<li><p>reply directly,</p>
</li>
<li><p>create a thread and respond there,</p>
</li>
<li><p>or send an embed (for support-type replies).</p>
</li>
</ul>
<p>If a thread can’t be created, you fall back to replying in the main channel. Message history is capped at 20 to keep things lightweight.</p>
<p>Note that we’re not really using <code>previousMessages</code> much at the moment in the application, but I’ve prepared everything you need to handle querying previous conversations. You could easily create a new LangGraph node that queries or reasons over history if the bot needs to reference past conversations. (Take this as your challenge!)</p>
<p>This project should give you a basic idea of how you can use LangGraph + Composio to build a somewhat useful bot that can already handle decent stuff. There’s a lot more you could improve. I’ll leave that up to you. ✌️</p>
<p>Here’s a quick demo of what we’ve built so far:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/aeQKN0nMGRg" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<hr>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>By now you should have a good idea of how LangGraph works and also how to power the bot with integrations using Composio.</p>
<p>This is just a fraction of what you can do. Try adding more features and more integration support to the bot to fit your workflow. This can come in really handy.</p>
<p>If you got lost somewhere while coding along, you can find the source code <a target="_blank" href="https://github.com/shricodev/discord-bot-langgraph-composio">here</a>.</p>
<p>So, that is it for this article. Thank you so much for reading! See you next time. 🫡</p>
<p>Love to build cool stuff like this? I regularly build such stuff every few weeks. Feel free to reach out to me here:</p>
<ul>
<li><p>GitHub: <a target="_blank" href="http://github.com/shricodev">github.com/shricodev</a></p>
</li>
<li><p>Portfolio: <a target="_blank" href="http://techwithshrijal.com">techwithshrijal.com</a></p>
</li>
<li><p>LinkedIn: <a target="_blank" href="http://linkedin.com/in/iamshrijal">linkedin.com/in/iamshrijal</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Host Local LLMs in a Docker Container on Azure ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever run into a situation where you want to test some local AI models, but your computer doesn't have enough specs to run them? Or maybe you just don't like bloating your computer with a ton of AI models? You're not alone in this. I’ve faced... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/host-llms-locally-in-docker-on-azure/</link>
                <guid isPermaLink="false">67e42705cb510eea06095d36</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shrijal Acharya ]]>
                </dc:creator>
                <pubDate>Wed, 26 Mar 2025 16:10:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1743005422195/56c65ac2-7a0d-4bd6-b969-a4c02eb3c42e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever run into a situation where you want to test some local AI models, but your computer doesn't have enough specs to run them? Or maybe you just don't like bloating your computer with a ton of AI models?</p>
<p>You're not alone in this. I’ve faced this exact issue, and I was able to solve it with the help of a spare VM. So the only thing you'll need is a spare PC somewhere that you can access.</p>
<p>Here, I'm using Azure, but the process should be fairly simple for other cloud providers as well. Even if you have a homelab with your old PC or something that you can SSH into, the only thing you'll have to change is the commands that deal with Azure. Everything else should work just fine.</p>
<p>And the best part? We will be doing everything inside a Docker container. So if you ever want to remove all the AI models, just remove the container, and you're all set. Even your VM is not going to install anything locally, pure Docker! 👌</p>
<p>In this article, I will show you how to host local LLMs with Ollama in a Docker container on an Azure Virtual Machine.</p>
<p><strong>What you will learn: 👀</strong></p>
<ul>
<li><p>How to use Ollama to run multiple LLMs on a single machine.</p>
</li>
<li><p>How to set up <code>ollama</code> and <code>ollama-webui</code> containers with <code>docker-compose</code>.</p>
</li>
<li><p>How to create a VM on Azure and configure everything using the Azure CLI.</p>
</li>
<li><p>How to restrict Azure VM access to your public IP using Azure CLI.</p>
</li>
</ul>
<p>By the end of the article, you will have a fully functional Azure VM capable of running all your chosen AI models (dependent on its specs, of course).</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-environment">How to Set Up the Environment</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-azure-virtual-machine">How to Set Up the Azure Virtual Machine</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-a-resource-group">Create a Resource Group</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-virtual-machine">Create the Virtual Machine</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-get-the-virtual-machine-public-ip">Get the Virtual Machine Public IP</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-the-vm-network-to-allow-access-only-from-the-users-ip">Configure the VM Network to Allow Access Only from the User's IP</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-vm-for-running-ai-models">How to Set Up the VM for Running AI Models</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-set-up-the-virtual-machine">Set Up the Virtual Machine</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-set-up-docker-compose">Set Up Docker Compose</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-deploy-the-containers">Deploy the Containers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-run-the-llms-locally-inside-the-docker-container">Run the LLMs Locally Inside the Docker Container</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-environment">How to Set Up the Environment</h2>
<p>💁 You will mostly be writing Bash, so make sure that you know some basics of shell scripting before moving forward.</p>
<p>Create a folder to keep all your source code for the project:</p>
<pre><code class="lang-bash">mkdir run-local-ai-models-docker-azure \
    &amp;&amp; <span class="hljs-built_in">cd</span> run-local-ai-models-docker-azure
mkdir azure scripts
</code></pre>
<p>Here, the <code>azure</code> directory will hold all the scripts required to work with the Azure VM, and the <code>scripts</code> directory will hold everything needed to set up the VM to run the LLMs.</p>
<p>Create a new <code>.env</code> file in the root of the project with the following environment variables. Make sure you change the size, name, location and the models as you like.</p>
<pre><code class="lang-plaintext">RESOURCE_GROUP="ollama-vm-rg"
LOCATION="eastus"
VM_NAME="ollama-vm"
VM_SIZE="Standard_D2s_v3"
USERNAME="&lt;your-username&gt;"

# This will be used as a backup when we can't fetch the IP
# from the 'api.ipify.org'
IP_ADDRESS="&lt;your-ip-address&gt;"

# Change it to whatever models you like.
OLLAMA_DEFAULT_MODEL="qwen2.5-coder:3b"

# Make sure to use "," when separating multiple models.
OLLAMA_ADDITIONAL_MODELS="deepseek-r1:1.5b,tinyllama:1.1b"

WEBUI_PORT=3000
OLLAMA_PORT=11434
</code></pre>
<p>And finally, you need to have Azure CLI installed. Follow the installation instructions shown <a target="_blank" href="https://learn.microsoft.com/en-us/cli/azure">here</a> to install it locally on your machine.</p>
<p>Once, you have it installed, authenticate the CLI with your Azure account using the following command:</p>
<pre><code class="lang-bash">az login
</code></pre>
<p>Once you are logged in, the setup is complete, and you can start coding the project. 🎉</p>
<h2 id="heading-how-to-set-up-the-azure-virtual-machine">How to Set Up the Azure Virtual Machine</h2>
<p>In this section, I'll show you how to set up your Azure VM using the Azure CLI <code>az</code>. You'll do everything from making a separate resource group to setting up the network to only allow access from your IP address, and finally, creating the VM.</p>
<p>When creating a new file for this section, make sure you do it in the <code>azure</code> directory.</p>
<h3 id="heading-create-a-resource-group">Create a Resource Group</h3>
<p>First, begin by creating a new resource group for your Virtual Machine. But what’s a resource group? Basically, a resource group is like a container that holds related resources, such as the VM, storage, NSGs and all. It helps organize these resources quickly, making it easier to deploy, update, and delete them all at once. In short, just think of it as a way to group related stuff together.</p>
<p>Create a new file called <code>create-resource-group.sh</code> and add the following lines of code:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -e

SCRIPT_DIR=<span class="hljs-string">"<span class="hljs-subst">$( cd <span class="hljs-string">"<span class="hljs-subst">$( dirname <span class="hljs-string">"<span class="hljs-variable">${BASH_SOURCE[0]}</span>"</span> )</span>"</span> &amp;&amp; pwd )</span>"</span>
PROJECT_ROOT=<span class="hljs-string">"<span class="hljs-subst">$(dirname <span class="hljs-string">"<span class="hljs-variable">$SCRIPT_DIR</span>"</span>)</span>"</span>

<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.env"</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Creating resource group '<span class="hljs-variable">$RESOURCE_GROUP</span>' in location '<span class="hljs-variable">$LOCATION</span>'..."</span>
az group create --name <span class="hljs-variable">$RESOURCE_GROUP</span> --location <span class="hljs-variable">$LOCATION</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Resource group created successfully."</span>
</code></pre>
<p>Notice the <code>set -e</code> command. By adding this, its telling the script to stop if any step causes an error. Without this, the script would keep running even if a step fails, which would lead to errors. Remember this command, as it is in all the scripts you’ll write.</p>
<p>Next, it sources the <code>.env</code> file to access the environment variables. After that, it runs the <code>az</code> command to create a resource group with the given name and location.</p>
<p>Now, run the following command:</p>
<pre><code class="lang-bash">bash create-resource-group.sh
</code></pre>
<p>To check if it worked, go to your Azure account and look under the Resource groups section for your new resource group.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742103607596/a4545962-a77f-466f-9866-83345359dfa2.png" alt="Newly creted resource group on Azure." class="image--center mx-auto" width="1590" height="517" loading="lazy"></p>
<p>If you see your resource group on the list, you’re good to go.</p>
<h3 id="heading-create-the-virtual-machine">Create the Virtual Machine</h3>
<p>Now that the resource group is created, you can now move forward to creating the virtual machine itself. This script is also going to be fairly similar to the earlier one.</p>
<p>Create a new file called <code>create-vm.sh</code> and add the following lines of code:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -e

SCRIPT_DIR=<span class="hljs-string">"<span class="hljs-subst">$( cd <span class="hljs-string">"<span class="hljs-subst">$( dirname <span class="hljs-string">"<span class="hljs-variable">${BASH_SOURCE[0]}</span>"</span> )</span>"</span> &amp;&amp; pwd )</span>"</span>
PROJECT_ROOT=<span class="hljs-string">"<span class="hljs-subst">$(dirname <span class="hljs-string">"<span class="hljs-variable">$SCRIPT_DIR</span>"</span>)</span>"</span>

<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.env"</span>

VM_EXISTS=$(az vm show --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --name <span class="hljs-variable">$VM_NAME</span> --query <span class="hljs-string">"name"</span> -o tsv 2&gt;/dev/null || <span class="hljs-built_in">echo</span> <span class="hljs-string">""</span>)

<span class="hljs-keyword">if</span> [ -n <span class="hljs-string">"<span class="hljs-variable">$VM_EXISTS</span>"</span> ]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"VM '<span class="hljs-variable">$VM_NAME</span>' already exists in resource group '<span class="hljs-variable">$RESOURCE_GROUP</span>'."</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Please choose a different VM name or use the existing VM."</span>
    <span class="hljs-built_in">exit</span> 1
<span class="hljs-keyword">fi</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Creating VM '<span class="hljs-variable">$VM_NAME</span>'..."</span>
az vm create \
  --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> \
  --name <span class="hljs-variable">$VM_NAME</span> \
  --image Ubuntu2204 \
  --admin-username <span class="hljs-variable">$USERNAME</span> \
  --generate-ssh-keys \
  --size <span class="hljs-variable">$VM_SIZE</span> \
  --public-ip-sku Standard

<span class="hljs-comment"># The above command generates the ssh key as "id_rsa", if you already</span>
<span class="hljs-comment"># have a key called id_rsa in the ~/.ssh directory, make sure to name it to something else</span>
ssh-add ~/.ssh/id_rsa

<span class="hljs-built_in">echo</span> <span class="hljs-string">"VM created successfully."</span>
</code></pre>
<p>First, it checks if a VM with the same name is already in the resource group. If it isn't, then it creates a new VM there, providing details like size, image, username, and asking it to generate an SSH key which it will use to log in to the VM.</p>
<p>I chose Ubuntu because, in another section where you’ll set up Docker, I’ve used Debian steps. If you want to use a different distro, make sure to update that script, too.</p>
<p>Now, run the following command:</p>
<pre><code class="lang-bash">bash create-vm.sh
</code></pre>
<p>After running the command, under the VM section, you should see your newly created VM.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742106067836/092e5fe1-505a-4d5d-bd6f-59aa707afe55.png" alt="List of Virtual Machines on Azure." class="image--center mx-auto" width="1539" height="517" loading="lazy"></p>
<p>There you have it, the VM is up and running. You could manually go into the VM and set it up, but I will show you how to automate all of these steps as well, because we are devs, remember? 😉</p>
<h3 id="heading-get-the-virtual-machine-public-ip">Get the Virtual Machine Public IP</h3>
<p>So, now that you have the VM created perfectly, it’s time to fetch the public IP of the VM so you can then use SSH to login.</p>
<p>Create a new file called <code>get-vm-details.sh</code> and add the following lines of code:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -e

SCRIPT_DIR=<span class="hljs-string">"<span class="hljs-subst">$( cd <span class="hljs-string">"<span class="hljs-subst">$( dirname <span class="hljs-string">"<span class="hljs-variable">${BASH_SOURCE[0]}</span>"</span> )</span>"</span> &amp;&amp; pwd )</span>"</span>
PROJECT_ROOT=<span class="hljs-string">"<span class="hljs-subst">$(dirname <span class="hljs-string">"<span class="hljs-variable">$SCRIPT_DIR</span>"</span>)</span>"</span>

<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.env"</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Getting VM details..."</span>

PUBLIC_IP=$(az vm show --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --name <span class="hljs-variable">$VM_NAME</span> --show-details --query publicIps -o tsv)

<span class="hljs-keyword">if</span> [ -z <span class="hljs-string">"<span class="hljs-variable">$PUBLIC_IP</span>"</span> ]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Error: Could not retrieve public IP for VM '<span class="hljs-variable">$VM_NAME</span>'"</span>
    <span class="hljs-built_in">exit</span> 1
<span class="hljs-keyword">fi</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"VM Public IP: <span class="hljs-variable">$PUBLIC_IP</span>"</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Ollama API endpoint: http://<span class="hljs-variable">$PUBLIC_IP</span>:<span class="hljs-variable">$OLLAMA_PORT</span>"</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Web UI: http://<span class="hljs-variable">$PUBLIC_IP</span>:<span class="hljs-variable">$WEBUI_PORT</span>"</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"PUBLIC_IP=<span class="hljs-variable">$PUBLIC_IP</span>"</span> &gt; <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.vm_details.env"</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"VM details retrieved successfully."</span>
</code></pre>
<p>All it is doing is sourcing the <code>.env</code> and then using the <code>az</code> command to fetch the public IP of the VM and store it in a separate file called <code>.vm_details.env</code>. This way, in other scripts, it doesn't need to fetch the public IP repeatedly and can simply source the file to access it.</p>
<p>Now, run the following command:</p>
<pre><code class="lang-bash">bash get-vm-details.sh
</code></pre>
<h3 id="heading-configure-the-vm-network-to-allow-access-only-from-the-users-ip">Configure the VM Network to Allow Access Only from the User's IP</h3>
<p>Now that you have the VM working, there's a slight security issue with it. If you check your VM network settings, you'll see that there is no IP restriction. This means anyone can easily try to SSH into your VM.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742110598028/2789344f-ec3f-4fb4-99c5-a25452dd7154.png" alt="Azure VM default network settings for SSH" class="image--center mx-auto" width="1519" height="255" loading="lazy"></p>
<p>Allowing access from any source IP address is definitely not a good idea. You need to configure this to allow access only from your public IP, and also need to configure the port for <code>ollama</code> and <code>ollama-webui</code>.</p>
<p>Create a new file called <code>configure-network.sh</code> and add the following lines of code:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -e

SCRIPT_DIR=<span class="hljs-string">"<span class="hljs-subst">$( cd <span class="hljs-string">"<span class="hljs-subst">$( dirname <span class="hljs-string">"<span class="hljs-variable">${BASH_SOURCE[0]}</span>"</span> )</span>"</span> &amp;&amp; pwd )</span>"</span>
PROJECT_ROOT=<span class="hljs-string">"<span class="hljs-subst">$(dirname <span class="hljs-string">"<span class="hljs-variable">$SCRIPT_DIR</span>"</span>)</span>"</span>

<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.env"</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Configuring network security..."</span>

<span class="hljs-comment"># Get current public IP address with fallback to .env one.</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Retrieving current public IP address..."</span>
IP_ADDRESS_CURRENT=$(curl -s https://api.ipify.org || <span class="hljs-built_in">echo</span> <span class="hljs-string">""</span>)
<span class="hljs-keyword">if</span> [ -z <span class="hljs-string">"<span class="hljs-variable">$IP_ADDRESS_CURRENT</span>"</span> ]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Warning: Could not retrieve IP from api.ipify.org, falling back to .env value"</span>
    IP_ADDRESS_CURRENT=<span class="hljs-variable">$IP_ADDRESS</span>
<span class="hljs-keyword">fi</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Using IP address: <span class="hljs-variable">$IP_ADDRESS_CURRENT</span>"</span>

NSG_NAME=$(az network nsg list --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --query <span class="hljs-string">"[?contains(name, '<span class="hljs-variable">${VM_NAME}</span>')].name"</span> -o tsv)
<span class="hljs-keyword">if</span> [ -z <span class="hljs-string">"<span class="hljs-variable">$NSG_NAME</span>"</span> ]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Error: Could not find NSG for VM '<span class="hljs-variable">$VM_NAME</span>'"</span>
    <span class="hljs-built_in">exit</span> 1
<span class="hljs-keyword">fi</span>

<span class="hljs-function"><span class="hljs-title">create_or_update_nsg_rule</span></span>() {
    <span class="hljs-built_in">local</span> RULE_NAME=<span class="hljs-variable">$1</span>
    <span class="hljs-built_in">local</span> PORT=<span class="hljs-variable">$2</span>
    <span class="hljs-built_in">local</span> PRIORITY=<span class="hljs-variable">$3</span>

    RULE_EXISTS=$(az network nsg rule list --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --nsg-name <span class="hljs-variable">$NSG_NAME</span> --query <span class="hljs-string">"[?name=='<span class="hljs-variable">$RULE_NAME</span>'].name"</span> -o tsv)

    <span class="hljs-keyword">if</span> [ -z <span class="hljs-string">"<span class="hljs-variable">$RULE_EXISTS</span>"</span> ]; <span class="hljs-keyword">then</span>
        <span class="hljs-built_in">echo</span> <span class="hljs-string">"Creating new rule: <span class="hljs-variable">$RULE_NAME</span> for port <span class="hljs-variable">$PORT</span>..."</span>
        az network nsg rule create --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --nsg-name <span class="hljs-variable">$NSG_NAME</span> \
            --name <span class="hljs-string">"<span class="hljs-variable">$RULE_NAME</span>"</span> \
            --protocol tcp --direction inbound --priority <span class="hljs-variable">$PRIORITY</span> \
            --source-address-prefix <span class="hljs-variable">$IP_ADDRESS_CURRENT</span> --source-port-range <span class="hljs-string">"*"</span> \
            --destination-address-prefix <span class="hljs-string">"*"</span> --destination-port-range <span class="hljs-variable">$PORT</span> \
            --access allow
    <span class="hljs-keyword">else</span>
        <span class="hljs-built_in">echo</span> <span class="hljs-string">"Updating existing rule: <span class="hljs-variable">$RULE_NAME</span> with new IP address..."</span>
        az network nsg rule update --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --nsg-name <span class="hljs-variable">$NSG_NAME</span> \
            --name <span class="hljs-string">"<span class="hljs-variable">$RULE_NAME</span>"</span> \
            --source-address-prefix <span class="hljs-variable">$IP_ADDRESS_CURRENT</span>
    <span class="hljs-keyword">fi</span>
}

<span class="hljs-comment"># Check if 'default-allow-ssh' exists</span>
SSH_RULE_EXISTS=$(az network nsg rule list --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --nsg-name <span class="hljs-variable">$NSG_NAME</span> --query <span class="hljs-string">"[?name=='default-allow-ssh'].name"</span> -o tsv)
<span class="hljs-keyword">if</span> [ -n <span class="hljs-string">"<span class="hljs-variable">$SSH_RULE_EXISTS</span>"</span> ]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Updating existing SSH rule (default-allow-ssh) with restricted IP..."</span>
    az network nsg rule update --resource-group <span class="hljs-variable">$RESOURCE_GROUP</span> --nsg-name <span class="hljs-variable">$NSG_NAME</span> \
        --name <span class="hljs-string">"default-allow-ssh"</span> \
        --source-address-prefix <span class="hljs-variable">$IP_ADDRESS_CURRENT</span>
<span class="hljs-keyword">else</span>
    <span class="hljs-comment"># If no default SSH rule, create our own with a different priority</span>
    create_or_update_nsg_rule <span class="hljs-string">"SSH_Restricted"</span> 22 1010
<span class="hljs-keyword">fi</span>

<span class="hljs-comment"># Configure rules for Ollama and Web UI</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Opening ports for Ollama API and Web UI (restricted to your IP)..."</span>

create_or_update_nsg_rule <span class="hljs-string">"Port_<span class="hljs-variable">${OLLAMA_PORT}</span>_Restricted"</span> <span class="hljs-variable">$OLLAMA_PORT</span> 1001
create_or_update_nsg_rule <span class="hljs-string">"Port_<span class="hljs-variable">${WEBUI_PORT}</span>_Restricted"</span> <span class="hljs-variable">$WEBUI_PORT</span> 1002

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Network security configured successfully."</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Note: If your IP address changes, you'll need to run this script again to update the rules."</span>
</code></pre>
<p>Don't be scared by this script. It might seem complex, but it's actually pretty straightforward. The first thing it does is try to get the user's IP address. It first attempts to fetch the user's IP from a service called <a target="_blank" href="https://api.ipify.org">api.ipify.org</a> (because public IPs can change frequently), which returns your current public IP. If there's an error, it falls back to using the IP address stored in the <code>.env</code> file.</p>
<p>Next, it tries to get the VM's NSG (Network Security Group) because it's needed when creating a new NSG rule. If there’s an error, there's no point in continuing with the script, so it exits with an error status.</p>
<p>There's also a function called <code>create_or_update_nsg_rule</code>, which is used to create a new NSG rule if it doesn't exist. If it does exist, it simply updates it to allow access only from the user's IP address.</p>
<p>Finally, it creates or updates the SSH rule depending on whether it exists. It also sets up the rules for <code>ollama</code> and <code>ollama-webui</code> so you can access the exposed port on your local machine to test the models with <code>ollama-webui</code>.</p>
<p>Now, run the following command:</p>
<pre><code class="lang-bash">bash configure-network.sh
</code></pre>
<p>After running this command, you can view the changed network settings. There should be two new rules for port <code>11434</code> and <code>3000</code>, and most importantly, you should see that the source IP is limited to your public IP.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742113381994/24653c49-0476-4822-b646-ff7ee9fd2be6.png" alt="Azure VM configured network settings." class="image--center mx-auto" width="1460" height="290" loading="lazy"></p>
<p>Every configuration on the Azure side is now complete. All you need to do next is write a few more scripts to set up running AI models on your configured VM. ✌️</p>
<h2 id="heading-how-to-set-up-the-vm-for-running-ai-models">How to Set Up the VM for Running AI Models</h2>
<p>Now that all the configuration on the Azure side is complete, in this section, I will show you how you can configure everything on the VM side, from installing Docker to setting up containers, and deploying those containers in the Azure VM you just created.</p>
<p>When creating a new file for this section, ensure you do it in the <code>scripts</code> directory, except for the <code>docker-compose.yaml</code> file.</p>
<h3 id="heading-set-up-the-virtual-machine">Set Up the Virtual Machine</h3>
<p>Okay, so now that you have a VM up and running with some network settings configured, let’s set this thing up to install and set up Docker and start the service.</p>
<p>Create a new file called <code>setup-vm.sh</code> and add the following lines of code:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -e

SCRIPT_DIR=<span class="hljs-string">"<span class="hljs-subst">$( cd <span class="hljs-string">"<span class="hljs-subst">$( dirname <span class="hljs-string">"<span class="hljs-variable">${BASH_SOURCE[0]}</span>"</span> )</span>"</span> &amp;&amp; pwd )</span>"</span>
PROJECT_ROOT=<span class="hljs-string">"<span class="hljs-subst">$(dirname <span class="hljs-string">"<span class="hljs-variable">$SCRIPT_DIR</span>"</span>)</span>"</span>

<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.env"</span>
<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.vm_details.env"</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Setting up VM with Docker and dependencies..."</span>

ssh <span class="hljs-variable">$USERNAME</span>@<span class="hljs-variable">$PUBLIC_IP</span> &lt;&lt; <span class="hljs-string">'EOF'</span>
  <span class="hljs-comment"># Install Docker Engine</span>
  <span class="hljs-built_in">echo</span> <span class="hljs-string">"Installing Docker..."</span>

  <span class="hljs-comment"># From Docker documentation for debian based distros</span>
  <span class="hljs-comment"># Add Docker's official GPG key:</span>
  sudo apt-get update
  sudo apt-get install ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc

  <span class="hljs-built_in">echo</span> \
    <span class="hljs-string">"deb [arch=<span class="hljs-subst">$(dpkg --print-architecture)</span> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
    <span class="hljs-subst">$(. /etc/os-release &amp;&amp; echo <span class="hljs-string">"<span class="hljs-variable">${UBUNTU_CODENAME:-<span class="hljs-variable">$VERSION_CODENAME</span>}</span>"</span>)</span> stable"</span> | \
    sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
  sudo apt-get update

  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
  <span class="hljs-comment"># End of docker debian installation instructions</span>

  sudo usermod -aG docker <span class="hljs-variable">$USER</span>

  sudo apt-get install -y docker-compose

  sudo systemctl start docker.service

  sudo docker volume create ollama_data

  <span class="hljs-comment"># Here we will place our docker-compose.yaml file</span>
  mkdir -p ~/ollama-project
EOF

<span class="hljs-built_in">echo</span> <span class="hljs-string">"VM setup completed successfully."</span>
</code></pre>
<p>And as I said earlier, in this example, I’m using Ubuntu, so I’m following Debian instructions to install Docker. If you are using a different distro, make sure to change the docker setup commands. Then it simply creates a Docker volume because you will want to persist the container state and not lose it every single time.</p>
<p>Now, run the following command:</p>
<pre><code class="lang-bash">bash setup-vm.sh
</code></pre>
<p>If everything goes well, you should have the Docker engine installed on your machine. You can verify it with the following command:</p>
<pre><code class="lang-bash">ssh &lt;YOUR_VM_USERNAME&gt;@&lt;YOUR_VM_PUBLIC_IP&gt; &lt;&lt; <span class="hljs-string">'EOF'</span>
    docker --version
EOF
</code></pre>
<h3 id="heading-set-up-docker-compose">Set Up Docker Compose</h3>
<p>Create a new file called <code>docker-compose.yaml</code> at the root of the project – not inside the <code>scripts</code> directory this time – and add the following lines of code:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">"3.9"</span>
<span class="hljs-attr">services:</span>
  <span class="hljs-attr">ollama:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">ollama/ollama:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">ollama</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"11434:11434"</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">~/ollama_data:/root/.ollama</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">OLLAMA_HOST=0.0.0.0</span>
    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span>

  <span class="hljs-attr">ollama-webui:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">ghcr.io/ollama-webui/ollama-webui:main</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">ollama-webui</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"${WEBUI_PORT:-3000}:8080"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">OLLAMA_API_BASE_URL=http://ollama:11434/api</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">ollama</span>
    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span>
</code></pre>
<p>The Docker Compose file should be fairly straightforward to understand. It sets up two services, or rather two containers: <code>ollama</code> and <code>ollama-webui</code>. You need to expose the container port so that you can access it in the VM, whose port is already exposed during the VM network configuration, allowing you to access it on your local machine. Finally, it specifies the Docker volumes and a few environment variables, and that's it.</p>
<p>For the <code>ollama-webui</code> container, it’s necessary for the <code>ollama</code> service to be up and running first, so it depends on the <code>ollama</code> container. After all, what's the point of starting the UI if the service itself is not running, right?</p>
<h3 id="heading-deploy-the-containers">Deploy the Containers</h3>
<p>Now that Docker is installed on the VM, it's time to copy the <code>docker-compose.yaml</code> file into the VM and start the containers.</p>
<p>Create a new file called <code>deploy-containers.sh</code> and add the following lines of code:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -e

SCRIPT_DIR=<span class="hljs-string">"<span class="hljs-subst">$( cd <span class="hljs-string">"<span class="hljs-subst">$( dirname <span class="hljs-string">"<span class="hljs-variable">${BASH_SOURCE[0]}</span>"</span> )</span>"</span> &amp;&amp; pwd )</span>"</span>
PROJECT_ROOT=<span class="hljs-string">"<span class="hljs-subst">$(dirname <span class="hljs-string">"<span class="hljs-variable">$SCRIPT_DIR</span>"</span>)</span>"</span>

<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.env"</span>
<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.vm_details.env"</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Deploying Docker containers to the VM..."</span>

scp <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/docker-compose.yaml"</span> <span class="hljs-variable">$USERNAME</span>@<span class="hljs-variable">$PUBLIC_IP</span>:~/ollama-project/

ssh <span class="hljs-variable">$USERNAME</span>@<span class="hljs-variable">$PUBLIC_IP</span> &lt;&lt; <span class="hljs-string">'EOF'</span>
  <span class="hljs-built_in">cd</span> ~/ollama-project
  <span class="hljs-built_in">export</span> WEBUI_PORT=3000
  sudo docker-compose up -d
  <span class="hljs-built_in">echo</span> <span class="hljs-string">"Docker containers started successfully."</span>
EOF

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Deployment completed successfully."</span>
<span class="hljs-built_in">echo</span> <span class="hljs-string">"Web UI available at: http://<span class="hljs-variable">$PUBLIC_IP</span>:<span class="hljs-variable">$WEBUI_PORT</span>"</span>
</code></pre>
<p>It’s pretty simple here as well. First, it copies the <code>docker-compose</code> file into the <code>~/ollama-project</code> directory and then spins up the container in detached mode.</p>
<p>Now, the moment of truth. Run the following command, and if everything goes well, you should have two Docker containers running in your VM.</p>
<pre><code class="lang-bash">bash deploy-containers.sh
</code></pre>
<p>To see if it worked, run the following command to <code>ssh</code> into the VM and execute the <code>docker ps</code> command.</p>
<pre><code class="lang-bash">ssh &lt;YOUR_VM_USERNAME&gt;@&lt;YOUR_VM_PUBLIC_IP&gt; &lt;&lt; <span class="hljs-string">'EOF'</span>
    docker ps
EOF
</code></pre>
<p>Along with some SSH output, you should see something like this, and if both containers' statuses say <code>Up</code>, you're all good.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742638797106/fcce6983-6adb-4e62-93af-99dd59887f72.png" alt="List of running Docker Containers." class="image--center mx-auto" width="1920" height="695" loading="lazy"></p>
<p>By now, you should be able to visit this URL (<code>http://&lt;VM_PUBLIC_IP&gt;:3000</code>) to view the Web UI running. But there are no AI models to chat with, so let's fix that.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742639360587/3980f847-b76b-474f-9973-d0d5bbe2d36e.png" alt="A browser window showing the Ollama Web UI." class="image--center mx-auto" width="1003" height="134" loading="lazy"></p>
<h3 id="heading-run-the-llms-locally-inside-the-docker-container">Run the LLMs Locally Inside the Docker Container</h3>
<p>You’re almost there. All that is left is to install some models in Ollama. To install any model, all you need to do is <code>ollama run &lt;MODEL_NAME&gt;</code>. So, let’s do that inside a script that runs that command inside a Docker container, because remember you have Ollama running in a Docker container.</p>
<p>💡 <strong>GOOD TO KNOW:</strong> You can run any command inside a Docker container from outside using <code>docker exec &lt;CONTAINER_NAME&gt; &lt;COMMAND&gt;</code>. This is perfect for our situation because there's no need to be inside the Docker container. You just need to run one command, and that's all.</p>
<p>Create a new file called <code>run-models.sh</code> and add the following lines of code:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/usr/bin/env bash</span>
<span class="hljs-built_in">set</span> -e

SCRIPT_DIR=<span class="hljs-string">"<span class="hljs-subst">$( cd <span class="hljs-string">"<span class="hljs-subst">$( dirname <span class="hljs-string">"<span class="hljs-variable">${BASH_SOURCE[0]}</span>"</span> )</span>"</span> &amp;&amp; pwd )</span>"</span>
PROJECT_ROOT=<span class="hljs-string">"<span class="hljs-subst">$(dirname <span class="hljs-string">"<span class="hljs-variable">$SCRIPT_DIR</span>"</span>)</span>"</span>

<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.env"</span>
<span class="hljs-built_in">source</span> <span class="hljs-string">"<span class="hljs-variable">$PROJECT_ROOT</span>/.vm_details.env"</span>

<span class="hljs-keyword">if</span> [ -n <span class="hljs-string">"<span class="hljs-variable">$OLLAMA_DEFAULT_MODEL</span>"</span> ]; <span class="hljs-keyword">then</span>
  <span class="hljs-built_in">echo</span> <span class="hljs-string">"Running default model <span class="hljs-variable">$OLLAMA_DEFAULT_MODEL</span>..."</span>
  ssh <span class="hljs-variable">$USERNAME</span>@<span class="hljs-variable">$PUBLIC_IP</span> &lt;&lt; EOF
    sudo docker <span class="hljs-built_in">exec</span> ollama ollama run <span class="hljs-variable">$OLLAMA_DEFAULT_MODEL</span>
EOF
  <span class="hljs-built_in">echo</span> <span class="hljs-string">"Default model <span class="hljs-variable">$OLLAMA_DEFAULT_MODEL</span> run successfully."</span>
<span class="hljs-keyword">fi</span>

<span class="hljs-keyword">if</span> [ -n <span class="hljs-string">"<span class="hljs-variable">$OLLAMA_ADDITIONAL_MODELS</span>"</span> ]; <span class="hljs-keyword">then</span>
  <span class="hljs-built_in">echo</span> <span class="hljs-string">"additional models <span class="hljs-variable">$OLLAMA_ADDITIONAL_MODELS</span>..."</span>
  IFS=<span class="hljs-string">','</span> <span class="hljs-built_in">read</span> -ra MODELS &lt;&lt;&lt; <span class="hljs-string">"<span class="hljs-variable">$OLLAMA_ADDITIONAL_MODELS</span>"</span>

  <span class="hljs-keyword">for</span> MODEL <span class="hljs-keyword">in</span> <span class="hljs-string">"<span class="hljs-variable">${MODELS[@]}</span>"</span>; <span class="hljs-keyword">do</span>
    <span class="hljs-comment"># trim whitespace</span>
    MODEL=$(<span class="hljs-built_in">echo</span> <span class="hljs-string">"<span class="hljs-variable">$MODEL</span>"</span> | xargs)
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Running additional model <span class="hljs-variable">$MODEL</span>..."</span>
    ssh <span class="hljs-variable">$USERNAME</span>@<span class="hljs-variable">$PUBLIC_IP</span> &lt;&lt; EOF
      sudo docker <span class="hljs-built_in">exec</span> ollama ollama run <span class="hljs-variable">$MODEL</span>
EOF
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Additional model <span class="hljs-variable">$MODEL</span> run successfully."</span>
  <span class="hljs-keyword">done</span>
<span class="hljs-keyword">fi</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"All models have been processed successfully."</span>
</code></pre>
<p>All this script does is first check if the default model is set up in the env with <code>OLLAMA_DEFAULT_MODEL</code>. If it is, the script runs it and also runs any other models separated by commas in the <code>OLLAMA_ADDITIONAL_MODELS</code> env variable.</p>
<p>Now, run the following command:</p>
<pre><code class="lang-bash">bash run-models.sh
</code></pre>
<p>If everything goes well and you see the final <code>echo</code> message, then hurray! 🎉 You’ve successfully set up running LLMs inside a Docker container in an Azure VM.</p>
<p>Go ahead and refresh the Web UI, and you should see all your LLMs appearing in the list of available models. Choose any one you like and start chatting! 🔥</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742650146763/0d2e43b8-5653-4390-8f88-07a55d4966bc.png" alt="Response from an AI Model." class="image--center mx-auto" width="1920" height="1013" loading="lazy"></p>
<hr>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>That is it for this one. I hope you enjoyed it and, better yet, understood everything we did together. I build such stuff every other week and document them with blogs. Feel free to check out some of my previous tutorials on <a target="_blank" href="https://dev.to/shricodev">DEV</a> and <a target="_blank" href="https://www.freecodecamp.org/news/author/shricodev">freeCodeCamp</a>.</p>
<p>You can find the complete source code <a target="_blank" href="https://github.com/shricodev/local-ai-models-docker-azure">here</a>.</p>
<p>And hey, if you agree with the response from the above <code>qwen2.5-coder</code> model, here are my socials 😉:</p>
<ul>
<li><p><strong>GitHub:</strong> <a target="_blank" href="https://github.com/shricodev">github.com/shricodev</a></p>
</li>
<li><p><strong>Portfolio:</strong> <a target="_blank" href="https://www.techwithshrijal.com">techwithshrijal.com</a></p>
</li>
<li><p><strong>LinkedIn:</strong> <a target="_blank" href="https://www.linkedin.com/in/iamshrijal/">linkedin.com/in/iamshrijal</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Host Static Sites on Azure Static Web Apps for Free ]]>
                </title>
                <description>
                    <![CDATA[ In this article, I will show you how you can host your React/Next.js app or any static web app on Azure Static Web Apps.  I will be showing you both ways of doing it – through the GUI and with the CLI. I assume you already have built a ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-host-static-sites-on-azure-static-web-apps/</link>
                <guid isPermaLink="false">66bb9188867a396452a8027f</guid>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shrijal Acharya ]]>
                </dc:creator>
                <pubDate>Tue, 18 Jun 2024 10:59:18 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2024/06/host_static_sites_swa_azure.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, I will show you how you can host your React/Next.js app or any static web app on Azure Static Web Apps. </p>
<p>I will be showing you both ways of doing it – through the GUI and with the CLI.</p>
<p>I assume you already have built a project and optionally pushed it to GitHub or any other alternative, like GitLab or Bitbucket.</p>
<p>It's time to go live with your project and showcase it to the world. 💪 </p>
<p>Whether it is your first time working with Azure or you are already a champ, feel free to follow along. </p>
<h2 id="heading-what-is-cicd">What is CI/CD?</h2>
<p>Before we dive into Azure Static Web App, let me give you a brief overview of what CI/CD is.</p>
<p>Imagine you're building your cool new web app. Now, to make sure it's always up-to-date and running, you need something called Continuous Integration/Continuous Deployment (CI/CD). </p>
<p>Here's how it works:</p>
<ul>
<li><strong>Continuous Integration (CI)</strong> is the process of automatically building and testing your code whenever you make changes and ensuring it is working as you expect.</li>
<li><strong>Continuous Deployment (CD)</strong> is the process of automatically deploying your tested code to production.</li>
</ul>
<p>You might have already came across CI/CD if you've ever hosted your project on Vercel, Netlify, or any other site hosting platform, and noticed that once you push your local changes to your remote repository, those changes are reflected on the original hosted site in just a few minutes. Magic, huh? This is all possible with the help of CI/CD.</p>
<p>In short, CI/CD ensures that your project is tested and deployed whenever you push new commits to the repository hosting your project.</p>
<h2 id="heading-how-to-push-your-project-to-github">How to Push your Project to GitHub</h2>
<p>In this step, I will use GitHub as an example. If your project is already pushed to GitHub, feel free to skip this step. Otherwise, follow the steps shown below.</p>
<h3 id="heading-log-in-to-github-cli">Log in to GitHub CLI</h3>
<p>Did you know you can create a GitHub repository right from your command line?</p>
<p>Let's get started. Firstly, make sure you have GitHub CLI installed. For installation instructions, follow the steps shown <a target="_blank" href="https://github.com/cli/cli/blob/trunk/docs/install_linux.md">here</a>.</p>
<p>To authenticate your GitHub CLI to your account, run the below command:</p>
<pre><code class="lang-bash">gh auth login
</code></pre>
<p>Follow the steps displayed in your terminal to authenticate with GitHub. Once done, you can proceed to the next step.</p>
<h3 id="heading-create-a-repository">Create a repository</h3>
<p>Once you're logged in, you can run the below command to start an interactive repository creation mode:</p>
<pre><code class="lang-bash">gh repo create
</code></pre>
<p>Or, directly specify flags to it like so:</p>
<pre><code class="lang-bash">gh repo create &lt;repository_name&gt; --public --license mit --description &lt;repository_description&gt;
</code></pre>
<p>Running, this command will create a repository with all the specified options in your GitHub account.</p>
<h3 id="heading-push-your-changes">Push your changes</h3>
<p>Now, that you have already authenticated yourself and have created a repository in your GitHub account, it's time to push your local changes to the remote repository.</p>
<p>Run the following commands to push your local changes to the remote:</p>
<pre><code class="lang-bash">git branch -M main
git remote add origin https://github.com/&lt;username&gt;/&lt;repository_name&gt;.git
git push -u origin main
</code></pre>
<p>Note that if you have SSH authentication set up, you need to make sure you change the origin URL to follow SSH protocol.</p>
<p>That concludes the initial preparation. Now, let's proceed with creating the Azure Static Web App. 🚀</p>
<h2 id="heading-host-your-project-with-azure-swa-cli">Host your project with Azure SWA CLI</h2>
<p>In this section, you'll learn how to host your static project, whether it built with Next.js, React, or any other static site, using Azure SWA CLI.</p>
<p>First, you need to install the <code>@azure/static-web-apps-cli</code> package as a dev dependency. </p>
<p>If using <code>pnpm</code> as a package manager, run the below command. The command might vary based on different package managers.</p>
<pre><code class="lang-bash">pnpm install -g @azure/static-web-apps-cli
</code></pre>
<p>Now, build your project with the appropriate build command:</p>
<pre><code class="lang-bash">pnpm run build
</code></pre>
<p>It's time to deploy the application with the build folder. Run the below command to deploy it to Azure SWA:</p>
<pre><code class="lang-bash">swa deploy &lt;build_location&gt; --env production
</code></pre>
<p>If your application uses an API, then you need to pass the <code>api</code> folder location as a flag to the above command. So, your final command would be:</p>
<pre><code class="lang-bash">swa deploy &lt;build_location&gt; --api-location &lt;api_folder_location&gt; --env production
</code></pre>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/Untitled-design-5-.png" alt="Image" width="600" height="400" loading="lazy">
<em>Deploying the project with Azure Static Web App CLI.</em></p>
<p>That is it. Your site is deployed to Azure Static Web App through CLI. 🎉 You can view it in the shown URL. </p>
<p>There are further configuration which can be specified. For additional configuration, follow this <a target="_blank" href="https://learn.microsoft.com/en-us/azure/static-web-apps/static-web-apps-cli-configuration">link</a>.</p>
<h2 id="heading-how-to-host-your-project-with-azure-portal-gui">How to Host your project with Azure Portal GUI</h2>
<p>In this section, we will create another brand new Azure SWA through the Azure portal.</p>
<p>Head over to the Azure portal at <a target="_blank" href="https://portal.azure.com">https://portal.azure.com</a> and search for the Azure Static Web App.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/Screenshot-from-2024-06-15-16-24-56.png" alt="Image" width="600" height="400" loading="lazy">
<em>Azure portal search results for Static Web App.</em></p>
<p> Hit "Create" and you should be asked with a bunch of extra configurations.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/Untitled-design-1-.png" alt="Image" width="600" height="400" loading="lazy">
<em>Creating a new Azure Static Web App.</em></p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/Screenshot-2024-06-15-at-16-39-09-Microsoft-Azure.png" alt="Image" width="600" height="400" loading="lazy">
<em>Setting up the configurations for the Azure Static Web App.</em></p>
<p>For the Resource Group, create one and name it whatever you want. Also, give your web application a name.</p>
<p>If your project is hosted on GitHub, choose GitHub as the option and choose the repository from the dropdown menu. For the branch, select 'main' if you created the project from scratch with default settings.</p>
<p>If you are using any other alternatives, then select the one which satisfies your criteria.</p>
<p>Leave everything else as default and click on "Review + create" and wait till it is done hosting your project to Azure SWA.</p>
<p>In the Overview section of the app, you should see URL for your built project. Here is the overview of mine:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/Untitled-design-2-.png" alt="Image" width="600" height="400" loading="lazy">
<em>Azure Static Web App dashboard overview.</em></p>
<p>Visit the URL and you should have your site ready. 🥳</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/image-84.png" alt="Image" width="600" height="400" loading="lazy">
<em>Deployed project hero section.</em></p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>By now, you should have a general idea of how to host any static web app in Azure Static Web Apps.</p>
<p>While we could cover additional topics like adding a custom domain to our site, this may not apply to most of you, so I'm not including the steps in this article. To learn more, visit this <a target="_blank" href="https://learn.microsoft.com/en-us/azure/static-web-apps/custom-domain-external">link</a>.</p>
<p>So, that is it for this article. Thank you so much for reading! See you next time. 🫡</p>
<p>Now that you've had a sneak peek at my portfolio in the image above, why not get in touch? 😉 Feel free to connect with me here:</p>
<ul>
<li><strong>GitHub</strong>: <a target="_blank" href="https://github.com/shricodev">https://github.com/shricodev</a></li>
<li><strong>LinkedIn</strong>: <a target="_blank" href="https://linkedin.com/in/iamshrijal">https://linkedin.com/in/iamshrijal</a></li>
<li><strong>Twitter</strong>: <a target="_blank" href="https://twitter.com/shricodev">https://twitter.com/shricodev</a></li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
