<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Harshil Agrawal</title>
    <description>The latest articles on DEV Community by Harshil Agrawal (@harshil1712).</description>
    <link>https://dev.to/harshil1712</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F124095%2Fa2c1f992-7929-49c3-bb84-25d458aa7d41.png</url>
      <title>DEV Community: Harshil Agrawal</title>
      <link>https://dev.to/harshil1712</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/harshil1712"/>
    <language>en</language>
    <item>
      <title>Could Cloudflare Computer Simplify PromptMotion?</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Mon, 03 Aug 2026 17:59:06 +0000</pubDate>
      <link>https://dev.to/harshil1712/could-cloudflare-computer-simplify-promptmotion-2n7l</link>
      <guid>https://dev.to/harshil1712/could-cloudflare-computer-simplify-promptmotion-2n7l</guid>
      <description>&lt;p&gt;I wired together a filesystem, a container integration, an asset transfer path, agent tools, and a preview server to make &lt;a href="https://promptmotion.app" rel="noopener noreferrer"&gt;PromptMotion&lt;/a&gt; work. Each part is reasonable on its own. The complexity comes from making all of them agree on where the project files live.&lt;/p&gt;

&lt;p&gt;And today, Cloudflare published &lt;a href="https://github.com/cloudflare/computer" rel="noopener noreferrer"&gt;&lt;code&gt;@cloudflare/computer&lt;/code&gt;&lt;/a&gt;. It gives a Durable Object a persistent filesystem and lets multiple execution backends operate on the same files. That is almost exactly the abstraction I wish I had when I started PromptMotion.&lt;/p&gt;

&lt;p&gt;Cloudflare Computer would not replace PromptMotion's domain logic. It could replace the glue that moves project files between my agent, Durable Object, and render container.&lt;/p&gt;

&lt;p&gt;I have not migrated yet. This is my current assessment of which problems Computer could solve, which parts would stay, and how I plan to validate the idea.&lt;/p&gt;

&lt;p&gt;I checked the details below against the package README and runnable examples. The repository's &lt;code&gt;docs/&lt;/code&gt; directory is explicitly forward-looking and does not always describe what the package ships today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PromptMotion Needs So Much Infrastructure
&lt;/h2&gt;

&lt;p&gt;PromptMotion turns a text prompt into a video composition. The agent writes a &lt;a href="https://github.com/heygen-com/hyperframes" rel="noopener noreferrer"&gt;HyperFrames&lt;/a&gt; project made from HTML and GSAP timelines. The user sees a live browser preview and can render the same composition to an MP4.&lt;/p&gt;

&lt;p&gt;That product flow creates five infrastructure requirements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Project files must survive Durable Object restarts.&lt;/li&gt;
&lt;li&gt;The model needs safe tools for reading and editing those files.&lt;/li&gt;
&lt;li&gt;Final rendering needs a Linux container with Chromium and FFmpeg.&lt;/li&gt;
&lt;li&gt;The project and its assets must move into the container, and the MP4 must come back out.&lt;/li&gt;
&lt;li&gt;A browser-facing server must turn project files into an interactive preview.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Today, those requirements use separate paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                              +-&amp;gt; custom file tools
                              |
Agent -&amp;gt; @cloudflare/shell ---+-&amp;gt; generation archives
                              |
                              +-&amp;gt; preview handler -&amp;gt; ProjectAgent RPC -&amp;gt; R2

Workflow -&amp;gt; Sandbox container -&amp;gt; base64 assets -&amp;gt; hf-render
                              -&amp;gt; base64 MP4 -&amp;gt; R2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system works, but every boundary introduces another convention or conversion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Durable files need custom wrappers
&lt;/h3&gt;

&lt;p&gt;Each &lt;code&gt;ProjectAgent&lt;/code&gt; Durable Object owns an &lt;code&gt;@cloudflare/shell&lt;/code&gt; Workspace backed by its SQLite storage. On top of it, I built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;readAllProjectFiles()&lt;/code&gt; to turn the workspace into a &lt;code&gt;Record&amp;lt;string, string&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;archiveGeneration()&lt;/code&gt; and &lt;code&gt;getGenerationFiles()&lt;/code&gt; to maintain historical snapshots.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_files&lt;/code&gt;, &lt;code&gt;read_file&lt;/code&gt;, and &lt;code&gt;write_file&lt;/code&gt; tools with path guards and extension allowlists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The filesystem is durable. The application code around it is specific to PromptMotion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rendering duplicates the filesystem
&lt;/h3&gt;

&lt;p&gt;The render workflow starts a Cloudflare Container through &lt;code&gt;@cloudflare/sandbox&lt;/code&gt;, recreates the project directory, and writes every source file into it. It also reads assets from R2 and writes them into the container.&lt;/p&gt;

&lt;p&gt;The RPC boundary does not currently give this path a shared filesystem, so binary files travel as base64 strings. The rendered MP4 makes the same trip in reverse before the workflow uploads it to R2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Workspace files -&amp;gt; flatten paths -&amp;gt; container files
R2 assets       -&amp;gt; base64        -&amp;gt; container files
rendered MP4    -&amp;gt; base64        -&amp;gt; Worker memory -&amp;gt; R2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow also owns the Sandbox ID, sleep timer, and cleanup. A missed &lt;code&gt;destroy()&lt;/code&gt; can leave a container running longer than intended.&lt;/p&gt;

&lt;h3&gt;
  
  
  Previewing is a third file path
&lt;/h3&gt;

&lt;p&gt;The live preview cannot use the render workflow. It needs to respond to browser requests for HTML, JavaScript, and assets.&lt;/p&gt;

&lt;p&gt;My main Worker's &lt;code&gt;fetch&lt;/code&gt; handler calls the &lt;code&gt;ProjectAgent&lt;/code&gt; over RPC, loads an archived generation, injects the HyperFrames runtime fixes, and serves the result. Asset requests use another route that reads from R2.&lt;/p&gt;

&lt;p&gt;The agent, renderer, and preview server all consume the same project, but each reaches it differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Abstraction Cloudflare Computer Adds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@cloudflare/computer&lt;/code&gt; is a preview package that puts a SQLite-backed virtual filesystem inside a Durable Object. A &lt;code&gt;Workspace&lt;/code&gt; has two important surfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;workspace.fs&lt;/code&gt; provides a &lt;code&gt;node:fs/promises&lt;/code&gt;-shaped API for durable files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;workspace.runtime.exec()&lt;/code&gt; runs commands or JavaScript through a configured backend against those files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three execution backends currently ship:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A container backend with a real Linux userland and a FUSE-mounted workspace.&lt;/li&gt;
&lt;li&gt;A Worker shell backend powered by &lt;code&gt;just-bash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A Worker JavaScript backend that evaluates an ECMAScript module in a fresh Dynamic Worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part for PromptMotion is not the number of backends. It is that the host application and each backend operate on one durable file model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         +-&amp;gt; built-in agent tools
                         |
Agent -&amp;gt; Computer -------+-&amp;gt; Workspace.fs -&amp;gt; preview handler
Workspace                |
                         +-&amp;gt; computerd container -&amp;gt; hf-render
                                                   |
                                                   +-&amp;gt; MP4 stream -&amp;gt; R2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The preview handler remains, but the agent, preview path, and renderer no longer need separate ways to reconstruct the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Computer Maps to My Problems
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One durable filesystem instead of file-copying helpers
&lt;/h3&gt;

&lt;p&gt;Computer's filesystem accepts strings, &lt;code&gt;Uint8Array&lt;/code&gt;, and &lt;code&gt;ReadableStream&lt;/code&gt; values. Text reads can return UTF-8 strings, while binary reads return streams by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace/src/composition.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;composition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace/src/composition.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projectEntries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace/src&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That replaces the custom Workspace wrapper and gives the preview and render paths the same file API.&lt;/p&gt;

&lt;p&gt;Computer also has an opt-in git client backed by &lt;code&gt;isomorphic-git&lt;/code&gt;. I could initialize a repository per project and commit each accepted generation instead of copying every file into a snapshot directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;git&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;git&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`generation &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;generationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git is not enabled automatically. The Workspace must be configured with &lt;code&gt;createGitClient()&lt;/code&gt;, initialized once, and given an identity before this code works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built-in tools replace generic agent glue
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@cloudflare/computer/tools&lt;/code&gt; provides AI SDK tools named &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, and &lt;code&gt;ls&lt;/code&gt;. An &lt;code&gt;exec&lt;/code&gt; tool is available when shell backends are configured.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createAITools&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@cloudflare/computer/tools&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAITools&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxLines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I would still keep &lt;code&gt;load_skill&lt;/code&gt;, &lt;code&gt;list_assets&lt;/code&gt;, and &lt;code&gt;start_preview&lt;/code&gt; because they describe PromptMotion behavior. I would also preserve my file-extension policy with a thin wrapper because the built-in &lt;code&gt;write&lt;/code&gt; tool supports a byte limit, not an extension allowlist.&lt;/p&gt;

&lt;p&gt;This is the boundary I want: Computer owns generic file operations, while PromptMotion owns product policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The render container sees the same workspace
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;CloudflareContainerBackend&lt;/code&gt; runs &lt;code&gt;computerd&lt;/code&gt; inside a Cloudflare Container. Before a command runs, it synchronizes the Durable Object state into the container and exposes it at &lt;code&gt;/workspace&lt;/code&gt; through FUSE. Changes synchronize back after execution.&lt;/p&gt;

&lt;p&gt;The render step could operate on the project without recreating it file by file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;using&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hf-render . --output out/video.mp4 --quiet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace/src&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;render&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;exitCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stderr&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Render failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes the &lt;code&gt;restore-code-to-container&lt;/code&gt; step and the manual Sandbox ID. The backend handles container startup, reconnects, workspace synchronization, and command execution.&lt;/p&gt;

&lt;p&gt;It does not remove the container image. I still need Chromium, FFmpeg, &lt;code&gt;hyperframes&lt;/code&gt;, &lt;code&gt;chrome-headless-shell&lt;/code&gt;, and the &lt;code&gt;hf-render&lt;/code&gt; wrapper. The image must also run the &lt;code&gt;computerd&lt;/code&gt; daemon. The official container example shows the required Worker, Durable Object, &lt;code&gt;WorkspaceProxy&lt;/code&gt;, and container wiring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streams can replace base64 transfers
&lt;/h3&gt;

&lt;p&gt;Once the command finishes and its changes synchronize back, the workflow can read the MP4 as a stream and pass it to R2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace/src/out/video.mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VIDEOS_BUCKET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`videos/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;generationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.mp4`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;httpMetadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video/mp4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes the current &lt;code&gt;readFile(..., { encoding: "base64" })&lt;/code&gt;, &lt;code&gt;atob&lt;/code&gt;, and &lt;code&gt;Uint8Array&lt;/code&gt; conversion loop.&lt;/p&gt;

&lt;p&gt;There is a trade-off. The Workspace shares the Durable Object's roughly 10 GB storage limit, and the container-side filesystem is held in memory. Published benchmarks also show that large sequential reads through the FUSE mount are much slower than native disk. A direct container-to-R2 upload may still be the better design for large rendered videos.&lt;/p&gt;

&lt;p&gt;Computer gives me a stream instead of a base64 blob. I still need to measure whether synchronizing the MP4 through the Workspace is the right path.&lt;/p&gt;

&lt;h3&gt;
  
  
  R2 mounts unify file access, not HTTP serving
&lt;/h3&gt;

&lt;p&gt;Computer can mount an R2 bucket as a read-only subtree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;mounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/workspace/assets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;R2Bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSETS_BUCKET&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets &lt;code&gt;workspace.fs&lt;/code&gt; read assets through the same interface as project files. Before a container command reads a lazily mounted object, the Durable Object must hydrate it or prefetch the mount so bytes are available to synchronize.&lt;/p&gt;

&lt;p&gt;An R2 mount does not create a public &lt;code&gt;/assets/...&lt;/code&gt; URL. The preview server still needs an HTTP route that reads the mounted file and returns a response. Computer can replace the direct R2 call inside that route, but not the route itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Computer Does Not Solve
&lt;/h2&gt;

&lt;p&gt;The shared Workspace removes infrastructure glue, not PromptMotion logic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HyperFrames runtime patches stay.&lt;/strong&gt; The normalizer and runtime injection fix behavior in the HyperFrames player. Changing the filesystem does not change that. Moreover, I am not using the latest version of HyperFrames, maybe the updates already resolve this?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The render image stays.&lt;/strong&gt; &lt;code&gt;hf-render&lt;/code&gt; still needs Chromium, FFmpeg, and &lt;code&gt;PRODUCER_HEADLESS_SHELL_PATH&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The preview server stays.&lt;/strong&gt; The Worker JavaScript backend evaluates modules and returns output. It does not expose a module as an HTTP request handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation metadata stays.&lt;/strong&gt; D1 still tracks status, ownership, billing, and the final R2 key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2 stays.&lt;/strong&gt; Large uploads and final videos still need object storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The workflow decision stays.&lt;/strong&gt; Computer can execute the render command, but I still need to decide where retries, status transitions, and failure recovery live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This distinction is important. Computer could make the product easier to maintain without pretending that every part of the product becomes a filesystem call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration I Would Try
&lt;/h2&gt;

&lt;p&gt;I would validate the abstraction in three stages rather than replacing every layer at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Move source files first
&lt;/h3&gt;

&lt;p&gt;The first step is replacing &lt;code&gt;@cloudflare/shell&lt;/code&gt; with a Computer Workspace while keeping the existing preview and render paths.&lt;/p&gt;

&lt;p&gt;I would test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing project import into &lt;code&gt;/workspace/src&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Read, write, find, and archive behavior across Durable Object restarts.&lt;/li&gt;
&lt;li&gt;The built-in AI tools with my path and extension policies.&lt;/li&gt;
&lt;li&gt;Git snapshots against real generation histories.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isolates the storage migration from container and preview changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Run one render through computerd
&lt;/h3&gt;

&lt;p&gt;Next, I would configure the container backend using the official example as the starting point. The &lt;code&gt;computerd&lt;/code&gt; image tag should match the installed &lt;code&gt;@cloudflare/computer&lt;/code&gt; version.&lt;/p&gt;

&lt;p&gt;The useful measurements are concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time to synchronize a typical project and its assets.&lt;/li&gt;
&lt;li&gt;Container startup time for the first render and later renders.&lt;/li&gt;
&lt;li&gt;Time and memory used to synchronize the final MP4 back.&lt;/li&gt;
&lt;li&gt;Behavior when rendering succeeds but post-command synchronization fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If MP4 synchronization is too expensive, the container can upload the output directly to R2 while Computer still handles source files and command execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Point the preview server at the Workspace
&lt;/h3&gt;

&lt;p&gt;Finally, I would keep the current browser-facing routes but replace the custom archive RPC and direct R2 reads with &lt;code&gt;workspace.fs&lt;/code&gt; calls.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;using&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getWorkspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projectAgent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;composition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`/workspace/generations/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;generationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/src/composition.html`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HyperFrames injection remains in the handler. Only the file access path changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Live Render Progress?
&lt;/h2&gt;

&lt;p&gt;An exec handle is also a &lt;code&gt;ReadableStream&lt;/code&gt; of events. If a request owns the execution, it can transform those events into Server-Sent Events and stream command output to the browser.&lt;/p&gt;

&lt;p&gt;My current render runs inside an &lt;code&gt;AgentWorkflow&lt;/code&gt;, so this is not automatic. A workflow step cannot directly pipe its exec stream into a separate active HTTP response. I would need either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A request or agent session that owns the render execution.&lt;/li&gt;
&lt;li&gt;An event relay that persists workflow output and forwards it to connected clients.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Computer provides the stream, but PromptMotion still needs the delivery path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I Am Cautious
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@cloudflare/computer&lt;/code&gt; is explicitly marked preview-only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;APIs are unstable and the design is subject to change. Suitable for experiments, exploration and prototypes. It is NOT suitable for production use at this time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That leaves four risks to validate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API churn.&lt;/strong&gt; I would pin the package and matching &lt;code&gt;computerd&lt;/code&gt; image versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration and rollback.&lt;/strong&gt; Computer is not API-compatible with &lt;code&gt;@cloudflare/shell&lt;/code&gt;; both directions need an explicit data conversion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large-file behavior.&lt;/strong&gt; Source files match the intended agent-scale workload. Video assets and MP4 output may not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational ownership.&lt;/strong&gt; &lt;code&gt;SyncRetryScheduler&lt;/code&gt; can persist failed sync work, but the application still owns its Durable Object alarm and retry policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The preview status is why I would start with a branch and one real project rather than migrate production data immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Outcome I Want
&lt;/h2&gt;

&lt;p&gt;PromptMotion currently has three ways to reach the same project files: the agent Workspace, the render workflow, and the preview server. Cloudflare Computer could reduce those paths to one durable Workspace shared by the application and its execution backends.&lt;/p&gt;

&lt;p&gt;The potential wins are specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generic file tools and version history move into the Workspace layer.&lt;/li&gt;
&lt;li&gt;The render container operates on the same project instead of receiving a reconstructed copy.&lt;/li&gt;
&lt;li&gt;Binary data can move as streams instead of base64 strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The preview server, HyperFrames patches, render image, workflow policy, and R2 storage remain. That is a feature of the design, not a failure. Those pieces contain PromptMotion's product decisions. Computer could remove the infrastructure code around them.&lt;/p&gt;

&lt;p&gt;That is the migration I want to test. I will write a follow-up after running a real PromptMotion render through &lt;code&gt;computerd&lt;/code&gt;, with timings and the parts that did not work as expected.&lt;/p&gt;

&lt;p&gt;If you are testing Cloudflare Computer for a similar agent workload, feel free to hit me up on &lt;a href="https://twitter.com/harshil1712" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;. I would love to compare notes.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Further Reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/cloudflare/computer" rel="noopener noreferrer"&gt;&lt;code&gt;@cloudflare/computer&lt;/code&gt; on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cloudflare/computer/tree/main/examples/container" rel="noopener noreferrer"&gt;Runnable container example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/heygen-com/hyperframes" rel="noopener noreferrer"&gt;HyperFrames&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://promptmotion.app" rel="noopener noreferrer"&gt;PromptMotion&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>sandbox</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>How I Run the Pi Coding Agent on Cloudflare</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Mon, 03 Aug 2026 14:40:10 +0000</pubDate>
      <link>https://dev.to/cloudflare/how-i-run-the-pi-coding-agent-on-cloudflare-ld5</link>
      <guid>https://dev.to/cloudflare/how-i-run-the-pi-coding-agent-on-cloudflare-ld5</guid>
      <description>&lt;p&gt;I recently came across a &lt;a href="https://x.com/pidotdev/status/2081703649374281736?s=20" rel="noopener noreferrer"&gt;tweet&lt;/a&gt; about &lt;a href="https://pi.dev/docs/latest/sdk" rel="noopener noreferrer"&gt;Pi's programmatic APIs&lt;/a&gt;. I knew Pi as a coding agent, but its portable internals caught my attention. The agent loop, model providers, and session storage are available through lower-level packages rather than being tied to its terminal interface. This project uses &lt;code&gt;@earendil-works/pi-agent-core&lt;/code&gt; and &lt;code&gt;@earendil-works/pi-ai&lt;/code&gt; directly instead of embedding the full &lt;code&gt;@earendil-works/pi-coding-agent&lt;/code&gt; SDK.&lt;/p&gt;

&lt;p&gt;That made me think: can I run Pi entirely on Cloudflare's Developer Platform?&lt;/p&gt;

&lt;p&gt;Most coding agents assume they are running on a developer's machine. They expect a persistent local filesystem, a shell, long-lived processes, and somewhere to store conversation history. Cloudflare Workers provides a very different runtime. There is no machine waiting for the agent. Workers has a request-scoped virtual filesystem, but it does not persist files across requests or provide the host filesystem a local coding agent expects.&lt;/p&gt;

&lt;p&gt;This sounded like a fun challenge. Instead of making Workers behave like a laptop, I wanted to find the Cloudflare primitive for each capability Pi needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/harshil1712/pi-on-cf" rel="noopener noreferrer"&gt;Pi on Cloudflare&lt;/a&gt; is what came out of that experiment. It is a browser-based coding agent where each session has durable conversation history, an isolated filesystem, streaming model output, searchable prior sessions, generated application previews, and one-click deployment to Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fra0wxpnvwbmq4dmn5h5x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fra0wxpnvwbmq4dmn5h5x.png" alt="Screenshot of the Pi on Cloudflare project" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is still a single-user prototype, not a production multi-tenant service. However, building it taught me a lot about what a coding agent needs from its runtime. Let me walk you through the design decisions, the Cloudflare primitives I used, and the limitations I accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Wanted to Preserve from Pi
&lt;/h2&gt;

&lt;p&gt;Pi is more than a chat interface around an LLM. Its core packages provide the parts required to run a coding agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An agent harness that coordinates model calls and tool execution&lt;/li&gt;
&lt;li&gt;A provider abstraction for connecting models&lt;/li&gt;
&lt;li&gt;Streaming events for text, reasoning, and tools, plus lifecycle events such as save points&lt;/li&gt;
&lt;li&gt;An append-only session tree rather than a flat message list&lt;/li&gt;
&lt;li&gt;Branch navigation, labels, forks, clones, and compaction&lt;/li&gt;
&lt;li&gt;A storage interface that applications can implement for different runtimes&lt;/li&gt;
&lt;li&gt;Steering, follow-up, and abort operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The storage interface in &lt;code&gt;pi-agent-core&lt;/code&gt; was the part that made this experiment possible. It doesn't require the transcript to live in a local JSON file. I could provide my own implementation while retaining Pi's native entry types and tree structure.&lt;/p&gt;

&lt;p&gt;I didn't want to flatten Pi into another chat application with a list of messages. I wanted to keep its branches, compaction, tool loop, and model abstraction, then replace only the things that normally come from a local machine.&lt;/p&gt;

&lt;p&gt;The Cloudflare version currently supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creating, naming, cloning, forking, searching, and deleting sessions&lt;/li&gt;
&lt;li&gt;Streaming assistant text, reasoning, and tool activity&lt;/li&gt;
&lt;li&gt;Navigating branches without deleting later history&lt;/li&gt;
&lt;li&gt;Automatic and manual context compaction&lt;/li&gt;
&lt;li&gt;Durable workspace file operations&lt;/li&gt;
&lt;li&gt;Cross-session search and learned memory&lt;/li&gt;
&lt;li&gt;Building, previewing, and deploying generated React applications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pi on Cloudflare isn't a browser port of Pi's terminal interface. It is a new host for Pi's portable core.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;I started by listing the capabilities that a local runtime normally gives Pi: identity for each session, durable state, files, model access, real-time communication, a way to execute generated code, and a place to keep deployed source.&lt;/p&gt;

&lt;p&gt;Once I mapped those capabilities to Cloudflare products, the architecture looked like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw1lf7p16vqwjc8vf7qzh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw1lf7p16vqwjc8vf7qzh.png" alt="Architecture of the app" width="799" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are 2 Durable Object classes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PiSession&lt;/code&gt; owns one Pi conversation and its workspace&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PiRegistry&lt;/code&gt; owns the session catalog, transcript search index, lineage, and global memory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application itself is a &lt;a href="https://tanstack.com/start" rel="noopener noreferrer"&gt;TanStack Start&lt;/a&gt; application deployed on &lt;a href="https://developers.cloudflare.com/workers/" rel="noopener noreferrer"&gt;Cloudflare Worker&lt;/a&gt;. The browser connects to the Durable Objects through the &lt;a href="https://developers.cloudflare.com/agents/" rel="noopener noreferrer"&gt;Cloudflare Agents SDK&lt;/a&gt;. Model requests go through &lt;a href="https://developers.cloudflare.com/ai-gateway/" rel="noopener noreferrer"&gt;AI Gateway&lt;/a&gt;. Generated applications run in &lt;a href="https://developers.cloudflare.com/dynamic-workers/" rel="noopener noreferrer"&gt;Dynamic Workers&lt;/a&gt; during preview and become independent Workers when deployed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Durable Object per Pi Session
&lt;/h2&gt;

&lt;p&gt;The first question I had to answer was: what owns a Pi session when there is no long-lived local process?&lt;/p&gt;

&lt;p&gt;This is where Durable Objects clicked for me. I assigned one &lt;code&gt;PiSession&lt;/code&gt; Durable Object to each conversation.&lt;/p&gt;

&lt;p&gt;A coding-agent session is a natural coordination boundary. It has one active transcript, one selected branch, one workspace, and at most one model turn changing those resources at a time.&lt;/p&gt;

&lt;p&gt;Each &lt;code&gt;PiSession&lt;/code&gt; owns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The low-level &lt;code&gt;Session&lt;/code&gt; and &lt;code&gt;AgentHarness&lt;/code&gt; from &lt;code&gt;pi-agent-core&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The append-only transcript tree&lt;/li&gt;
&lt;li&gt;The active leaf in that tree&lt;/li&gt;
&lt;li&gt;Compaction settings&lt;/li&gt;
&lt;li&gt;An isolated filesystem&lt;/li&gt;
&lt;li&gt;Indexing and memory-extraction cursors&lt;/li&gt;
&lt;li&gt;Generated application metadata&lt;/li&gt;
&lt;li&gt;An in-memory build cache&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code for this mapping is surprisingly small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PiSession&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;harness&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;PiHarness&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PiSessionStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;workspace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Workspace&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Agents SDK &lt;code&gt;Agent&lt;/code&gt; class extends Durable Objects with WebSocket connections, RPC, and browser clients. Durable Objects provide the identity, coordination, and SQLite storage underneath.&lt;/p&gt;

&lt;p&gt;This gives each session an independent address and storage boundary. A busy session doesn't need to coordinate transcript writes with every other session.&lt;/p&gt;

&lt;p&gt;It also gives me a straightforward exclusivity rule. Before Pi starts a prompt, the session marks itself active. Concurrent operations that could change the same transcript or workspace are rejected until the turn finishes.&lt;/p&gt;

&lt;p&gt;Durable Objects solved the ownership and coordination problem, but I still needed to fit Pi's storage model into the Durable Object. That led to the next decision.&lt;/p&gt;

&lt;p&gt;Durable Objects are single-threaded, but requests can interleave while awaiting non-storage I/O such as model calls. The &lt;code&gt;active&lt;/code&gt; flag provides application-level exclusivity across the complete agent turn, not only an individual storage statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserving Pi's Session Tree in SQLite
&lt;/h2&gt;

&lt;p&gt;A conventional chat application often stores messages in a table and updates or deletes rows when users retry a prompt. Pi doesn't work that way.&lt;/p&gt;

&lt;p&gt;Each session entry has an ID, a parent ID, a type, and a timestamp. Together, those entries form a tree. Changing the active branch doesn't delete another branch. In the low-level &lt;code&gt;pi-agent-core&lt;/code&gt; session model used here, Pi appends a leaf-selection entry that points to the newly selected part of the tree.&lt;/p&gt;

&lt;p&gt;I preserve that representation directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pi_session_entries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;seq&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;AUTOINCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The serialized entry can be a message whose role is user, assistant, or tool result. Other entry types represent model, thinking-level, or active-tool changes, compaction, branch summaries, custom data, labels, session information, or leaf selection.&lt;/p&gt;

&lt;p&gt;Flattening everything into chat messages would discard the exact semantics I wanted to preserve. Pi's branch navigation and compaction logic expect the original entry relationships.&lt;/p&gt;

&lt;p&gt;Appending non-empty user or assistant text also creates an indexing event within the same synchronous SQLite transaction. Every entry creates a separate event that updates the registry's session metadata. In simplified form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transactionSync&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO pi_session_entries(id, type, entry) VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;messageText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createMessageIndexEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createSessionTouchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a small but important failure case here. If I stored the transcript first and indexing failed afterwards, search could silently miss a completed message. If I indexed first, search could return a message that had never been committed to the session.&lt;/p&gt;

&lt;p&gt;To avoid both cases, a background operation delivers the events to &lt;code&gt;PiRegistry&lt;/code&gt;, but writing indexable transcript text and its outbox event happens atomically. Delivery can be asynchronous and idempotent because non-empty user or assistant text cannot be stored without also becoming eligible for indexing.&lt;/p&gt;

&lt;p&gt;This is the transactional outbox pattern applied inside a Durable Object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving Pi a Durable Filesystem
&lt;/h2&gt;

&lt;p&gt;At this point, I had durable conversations, but that alone doesn't make a coding agent. Pi also needed files.&lt;/p&gt;

&lt;p&gt;A Worker doesn't expose the host machine's persistent POSIX filesystem. This is where the experimental &lt;a href="https://www.npmjs.com/package/@cloudflare/shell" rel="noopener noreferrer"&gt;&lt;code&gt;@cloudflare/shell&lt;/code&gt;&lt;/a&gt; package came in. It gave me a virtual filesystem backed by the session's Durable Object SQLite database.&lt;/p&gt;

&lt;p&gt;Every Pi session therefore gets its own isolated workspace. Files survive Durable Object eviction because they live in durable storage rather than in the object's memory.&lt;/p&gt;

&lt;p&gt;I expose 6 core filesystem tools to the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;read&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;write&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;edit&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;list&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;find&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grep&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tool operates against the virtual workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writeTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;write&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Write a complete UTF-8 file to the durable workspace.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;executionMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sequential&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;throwIfAborted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;throwIfAborted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Wrote &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write and edit tools run sequentially to prevent concurrent changes from racing against each other.&lt;/p&gt;

&lt;p&gt;I also expose the workspace in the browser so I can inspect, refresh, and download files. I didn't want the filesystem to be an invisible implementation detail hidden behind the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  But What About a Shell?
&lt;/h3&gt;

&lt;p&gt;The obvious next question is why I didn't give Pi a process sandbox. This version doesn't provide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A POSIX shell&lt;/li&gt;
&lt;li&gt;Native processes&lt;/li&gt;
&lt;li&gt;&lt;code&gt;npm install&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Arbitrary package scripts&lt;/li&gt;
&lt;li&gt;A development server&lt;/li&gt;
&lt;li&gt;A general network-fetch tool&lt;/li&gt;
&lt;li&gt;Native test or compiler commands&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is deliberate, and it is probably the biggest difference from running Pi locally.&lt;/p&gt;

&lt;p&gt;The first version needed persistent source files and a way to run Workers applications. It didn't need a general-purpose Linux environment. Durable Object SQLite and Dynamic Workers cover that narrower requirement with fewer moving parts.&lt;/p&gt;

&lt;p&gt;The system prompt makes that limitation explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE_SYSTEM_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are Pi running natively on Cloudflare Workers.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Use the durable workspace tools to inspect and modify files.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;There is no POSIX filesystem or native process runtime.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Do not claim to run shell commands.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, "no POSIX filesystem" means the agent is not given a persistent POSIX workspace or a process environment. I don't expose Workers' request-scoped &lt;code&gt;node:fs&lt;/code&gt; filesystem as an agent tool.&lt;/p&gt;

&lt;p&gt;If I wanted the agent to compile arbitrary native projects or run existing CLI tools, I would use &lt;a href="https://developers.cloudflare.com/containers/" rel="noopener noreferrer"&gt;Cloudflare Containers&lt;/a&gt; or the process-oriented &lt;a href="https://developers.cloudflare.com/sandbox/" rel="noopener noreferrer"&gt;Cloudflare Sandbox SDK&lt;/a&gt;. For React and Workers applications built from a controlled template, the Worker-native path is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running Pi's Model Loop Through AI Gateway
&lt;/h2&gt;

&lt;p&gt;With sessions and files in place, the next piece was model inference. Pi already separates the agent loop from its model provider, so I registered a custom provider backed by AI Gateway's OpenAI-compatible API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai-completions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudflare-ai-gateway&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/ai/v1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reasoning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;contextWindow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="nx"&gt;_384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;contextWindow&lt;/code&gt; and &lt;code&gt;maxTokens&lt;/code&gt; values above are conservative budgets configured in this adapter, not advertised model limits. The currently selected GLM-5.2 model has a documented 262,144-token context window on Workers AI.&lt;/p&gt;

&lt;p&gt;The committed configuration currently selects a Workers AI model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AI_GATEWAY_ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AI_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@cf/zai-org/glm-5.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AI_MEMORY_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@cf/zai-org/glm-5.2"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I liked this approach because AI Gateway gives the agent one model endpoint while the model remains a configuration choice. It also gives me a natural place to attach request metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;streamOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf-aig-gateway-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gatewayId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf-aig-metadata&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The session ID makes model traffic attributable to the Pi session that produced it. Memory extraction requests include an additional purpose field.&lt;/p&gt;

&lt;p&gt;The UI doesn't currently expose model or reasoning-level selection. The agent uses a fixed &lt;code&gt;medium&lt;/code&gt; thinking level and server-side model configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the Stream Back to the Browser
&lt;/h2&gt;

&lt;p&gt;The agent loop was now running inside a Durable Object, but a coding agent doesn't feel useful if the browser waits for the entire turn to finish. I wanted to show text, reasoning, and tool activity as Pi produced them.&lt;/p&gt;

&lt;p&gt;The application routes &lt;code&gt;/api/agents/*&lt;/code&gt; through the Agents SDK before passing other requests to TanStack Start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;createServerEntry&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agentResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;routeAgentRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api/agents&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;agentResponse&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ordinary session operations use callable RPC methods. Prompts use a streaming callable method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;callable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;streaming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Run Pi and forward its events to the browser.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A prompt moves through the system as follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser appends the user message optimistically.&lt;/li&gt;
&lt;li&gt;The Agents SDK sends the prompt to the session's Durable Object.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PiSession&lt;/code&gt; validates configuration and prevents a second active prompt.&lt;/li&gt;
&lt;li&gt;The session creates or reuses its in-memory &lt;code&gt;AgentHarness&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pi compacts the active branch if it is approaching the context budget.&lt;/li&gt;
&lt;li&gt;The model produces text, reasoning, and tool calls through AI Gateway.&lt;/li&gt;
&lt;li&gt;Pi events are translated into browser-safe stream events.&lt;/li&gt;
&lt;li&gt;Completed messages become part of the durable session tree during the turn.&lt;/li&gt;
&lt;li&gt;After a turn, a save point schedules asynchronous registry outbox delivery.&lt;/li&gt;
&lt;li&gt;The browser reloads the authoritative branch and workspace.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The client batches incoming deltas once per animation frame. Model streams can produce updates faster than React should render them, so batching reduces unnecessary UI work without changing what the user sees.&lt;/p&gt;

&lt;p&gt;There is a catch. Only completed Pi entries are durable. Partial token deltas and the browser's position within an active stream are not. If the Durable Object restarts during a turn, the client can reload completed durable entries but cannot reattach to the interrupted stream. Resumable active turns are something I still want to explore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching Across Sessions
&lt;/h2&gt;

&lt;p&gt;One Durable Object per session worked well until I needed to answer questions across them. How do I list every session, search old conversations, or carry a useful preference from one session into another?&lt;/p&gt;

&lt;p&gt;This is why I added a singleton &lt;code&gt;PiRegistry&lt;/code&gt; Durable Object.&lt;/p&gt;

&lt;p&gt;The registry handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Session discovery and metadata&lt;/li&gt;
&lt;li&gt;Names and timestamps&lt;/li&gt;
&lt;li&gt;Parent and source lineage&lt;/li&gt;
&lt;li&gt;Full-text transcript search&lt;/li&gt;
&lt;li&gt;Regular-expression search&lt;/li&gt;
&lt;li&gt;Idempotent index-event processing&lt;/li&gt;
&lt;li&gt;Deletion tombstones&lt;/li&gt;
&lt;li&gt;Learned memory shared across sessions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The registry indexes non-empty user and assistant text with SQLite FTS5. It intentionally excludes reasoning, tool output, compaction summaries, and workspace files.&lt;/p&gt;

&lt;p&gt;I deliberately kept the search lexical rather than adding embeddings and a vector database. SQLite FTS5 has lower operational complexity and produces predictable results for session names, phrases, and code fragments. The agent can call a &lt;code&gt;session_search&lt;/code&gt; tool to retrieve relevant text from earlier sessions.&lt;/p&gt;

&lt;p&gt;Deletion tombstones handle an important race. Index events travel asynchronously from a session to the registry. Without a tombstone, a delayed event could recreate metadata for a deleted session. The registry records the deletion and rejects later events for that session.&lt;/p&gt;

&lt;h3&gt;
  
  
  Learned Memory
&lt;/h3&gt;

&lt;p&gt;The registry also stores global memories classified as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Preferences&lt;/li&gt;
&lt;li&gt;Facts&lt;/li&gt;
&lt;li&gt;Instructions&lt;/li&gt;
&lt;li&gt;Decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Memory can change through an explicit model tool or through background extraction after completed turns. The extraction prompt treats transcript contents as untrusted data and requires every change to cite a user-authored source entry.&lt;/p&gt;

&lt;p&gt;Later sessions receive those memories in their system prompt.&lt;/p&gt;

&lt;p&gt;Memory can become dangerous quickly if an agent stores everything it sees. I therefore kept this feature conservative. It limits the number and total size of memories, uses optimistic version checks for background extraction updates and deletions, and rejects selected secret patterns. Explicit updates through the memory tool do not currently use the same version check. Those checks are guardrails, not a complete data-loss-prevention system.&lt;/p&gt;

&lt;p&gt;Memory is currently global to the deployment. It isn't separated by user because the application doesn't yet have a user identity model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building an App Without a Build Process
&lt;/h2&gt;

&lt;p&gt;This was the part I was most curious about. Writing source files in SQLite is useful, but I wanted Pi to build something I could actually open and deploy. How do you build a React application when you cannot spawn Vite as a process?&lt;/p&gt;

&lt;p&gt;When the user asks Pi to build an application, the model calls &lt;code&gt;initialize_app&lt;/code&gt;. That tool copies a React template pinned to a full Git commit into the durable workspace.&lt;/p&gt;

&lt;p&gt;Pinning the commit gives the agent a known project shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React code lives under &lt;code&gt;/src&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Worker API code lives at &lt;code&gt;/worker/index.ts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The host controls the build inputs and compatibility settings&lt;/li&gt;
&lt;li&gt;The model cannot introduce arbitrary installation scripts into the build&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the user requests a preview or deployment, the application creates a deterministic source snapshot. It excludes directories such as &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, and &lt;code&gt;.wrangler&lt;/code&gt;, then enforces explicit limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,000 source files&lt;/li&gt;
&lt;li&gt;5 MiB per file&lt;/li&gt;
&lt;li&gt;25 MiB total source size&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The snapshot is sorted and hashed. The source hash lets the UI detect changes and lets the session reuse a matching in-memory build.&lt;/p&gt;

&lt;p&gt;Instead of starting a process, I call the experimental, Workers-runtime-only &lt;code&gt;@cloudflare/worker-bundler&lt;/code&gt; package directly inside the Worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;worker/index.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/main.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;assetConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;not_found_handling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;single-page-application&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;jsx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;automatic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;jsxImportSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;minify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is that this is not equivalent to spawning &lt;code&gt;vite build&lt;/code&gt;. The hosted builder uses a controlled entrypoint, fixed compatibility settings, and an in-memory source map. It doesn't execute arbitrary package scripts or custom Vite plugins.&lt;/p&gt;

&lt;p&gt;That constraint makes builds repeatable and keeps generated code inside the runtime model I designed for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Previewing Generated Code with Dynamic Workers
&lt;/h2&gt;

&lt;p&gt;A successful build left me with another interesting problem: the generated code didn't exist when I deployed the host Worker, but I still needed to execute it for a preview. &lt;a href="https://developers.cloudflare.com/dynamic-workers/" rel="noopener noreferrer"&gt;Dynamic Workers&lt;/a&gt; is currently in open beta.&lt;/p&gt;

&lt;p&gt;The Worker Loader binding lets the application create a Dynamic Worker from the generated modules. I use the bundle hash as its ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;previewApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;builtApp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BuiltApp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkerLoader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;builtApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bundleHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;previewCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;builtApp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntrypoint&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;get()&lt;/code&gt; instead of loading a new Worker for every request lets the runtime reuse a warm isolate when available. Reuse is not guaranteed, and the callback may run again if the runtime needs a new isolate.&lt;/p&gt;

&lt;p&gt;The preview Worker receives these application-configured limits and bindings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The generated Worker modules&lt;/li&gt;
&lt;li&gt;Bundled static assets&lt;/li&gt;
&lt;li&gt;No environment bindings&lt;/li&gt;
&lt;li&gt;A 50ms CPU limit&lt;/li&gt;
&lt;li&gt;A 20-subrequest limit&lt;/li&gt;
&lt;li&gt;Outbound network access inherited from the parent Worker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wrapper serves static files first, delegates API requests to the generated Worker, and falls back to &lt;code&gt;index.html&lt;/code&gt; for client-side routes.&lt;/p&gt;

&lt;p&gt;This is isolated Worker execution, not a Linux process sandbox. Generated code can handle requests using Workers APIs, but it can't spawn a process or access the Pi session's Durable Object unless I explicitly provide a binding. Because the current loader configuration does not set &lt;code&gt;globalOutbound: null&lt;/code&gt;, generated code can still make outbound requests with &lt;code&gt;fetch()&lt;/code&gt; or &lt;code&gt;connect()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For this project, the binding isolation is useful: the generated app receives no session storage or secrets. Outbound access remains a capability I would need to restrict before treating previews as untrusted code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Versioning and Deploying Generated Applications
&lt;/h2&gt;

&lt;p&gt;Once preview worked, I wanted a path from "the agent built this" to "this is a real deployed application." A preview is temporary. A deployed application needs durable source history and an independent Worker.&lt;/p&gt;

&lt;p&gt;The deployment pipeline has 2 destinations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Source goes to &lt;a href="https://developers.cloudflare.com/artifacts/" rel="noopener noreferrer"&gt;Cloudflare Artifacts&lt;/a&gt;, which is currently in beta and requires enrollment.&lt;/li&gt;
&lt;li&gt;The built application goes to the Workers API.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Artifacts provides Git-compatible, versioned storage. I create one repository per Pi session and publish the exact source snapshot used for the deployment.&lt;/p&gt;

&lt;p&gt;The application uses a short-lived repository token and a JavaScript Git client over the virtual workspace. This gives each deployment a source commit without requiring a native &lt;code&gt;git&lt;/code&gt; process.&lt;/p&gt;

&lt;p&gt;After publishing the source, the deployment client:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Uploads the generated static assets.&lt;/li&gt;
&lt;li&gt;Creates a Worker version.&lt;/li&gt;
&lt;li&gt;Creates a deployment that sends 100% of traffic to that version.&lt;/li&gt;
&lt;li&gt;Returns the resulting &lt;code&gt;workers.dev&lt;/code&gt; URL.&lt;/li&gt;
&lt;li&gt;Stores the source hash, bundle hash, commit SHA, version ID, and deployment ID in the session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each generated application becomes its own Worker. It doesn't keep running inside the original Pi session after deployment.&lt;/p&gt;

&lt;p&gt;Deleting a session also deletes its generated Worker and Artifacts repository when deployment metadata exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Each Cloudflare Primitive Is Here
&lt;/h2&gt;

&lt;p&gt;By this point, every primitive had a specific job. I didn't want to add a product because it might be useful later. I added it only when the implementation had a concrete requirement.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Why It Fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Workers&lt;/td&gt;
&lt;td&gt;Hosts the UI and request router&lt;/td&gt;
&lt;td&gt;One deployment serves the application, agent routes, and preview gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable Objects&lt;/td&gt;
&lt;td&gt;Owns sessions and the registry&lt;/td&gt;
&lt;td&gt;A session is a stateful coordination boundary with durable storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable Object SQLite&lt;/td&gt;
&lt;td&gt;Stores transcripts, settings, indexes, memory, and files&lt;/td&gt;
&lt;td&gt;Transactions preserve Pi entries and indexing events atomically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agents SDK&lt;/td&gt;
&lt;td&gt;Browser connections, RPC, and streaming&lt;/td&gt;
&lt;td&gt;It connects React clients directly to Durable Object-backed agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI Gateway&lt;/td&gt;
&lt;td&gt;Routes model requests&lt;/td&gt;
&lt;td&gt;Pi can use an OpenAI-compatible provider while requests retain gateway metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@cloudflare/shell&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Provides the virtual filesystem&lt;/td&gt;
&lt;td&gt;The agent gets durable file operations without assuming a host filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker Bundler&lt;/td&gt;
&lt;td&gt;Builds generated applications&lt;/td&gt;
&lt;td&gt;Source can be compiled in memory without spawning a build process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic Workers&lt;/td&gt;
&lt;td&gt;Runs application previews&lt;/td&gt;
&lt;td&gt;Generated Worker code executes in an isolated runtime with controlled bindings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Artifacts&lt;/td&gt;
&lt;td&gt;Versions generated source&lt;/td&gt;
&lt;td&gt;Each deployment has a Git-compatible source history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workers API&lt;/td&gt;
&lt;td&gt;Deploys generated applications&lt;/td&gt;
&lt;td&gt;Every generated app becomes an independently versioned Worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workers Static Assets&lt;/td&gt;
&lt;td&gt;Serves generated frontend files&lt;/td&gt;
&lt;td&gt;React assets and Worker API code deploy as one application&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I didn't add KV, R2, D1, Queues, Workflows, Containers, or Vectorize. None of them solved a requirement that the existing primitives couldn't already cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current Trade-offs
&lt;/h2&gt;

&lt;p&gt;I am happy with how much of Pi works on Cloudflare, but I don't want to present this as a production-ready replacement for a local coding agent. The architecture works because it chooses a narrower definition of a coding environment.&lt;/p&gt;

&lt;p&gt;The main limitations, in order of importance, are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The application has no user or tenant isolation&lt;/li&gt;
&lt;li&gt;Tool writes do not require user approval&lt;/li&gt;
&lt;li&gt;Global memory has no management interface and is shared across sessions&lt;/li&gt;
&lt;li&gt;There is no native shell or arbitrary process execution&lt;/li&gt;
&lt;li&gt;Only the controlled React template can use the hosted build path&lt;/li&gt;
&lt;li&gt;Active streams cannot resume after a Durable Object restart&lt;/li&gt;
&lt;li&gt;Forks copy the current workspace, not the filesystem as it existed at the selected transcript entry&lt;/li&gt;
&lt;li&gt;Search is lexical rather than semantic&lt;/li&gt;
&lt;li&gt;Generated previews have no bindings or secrets, but retain outbound network access&lt;/li&gt;
&lt;li&gt;Model and reasoning-level selection are server-controlled&lt;/li&gt;
&lt;li&gt;The singleton registry could become a coordination bottleneck at larger scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first point is the most important.&lt;/p&gt;

&lt;p&gt;This repository is a single-user prototype. Session UUIDs create storage boundaries, but they are not authorization controls. Anyone who can reach the deployment can potentially inspect sessions, modify files, call the model, influence global memory, or deploy applications using server-side credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Protect the entire Worker with &lt;a href="https://developers.cloudflare.com/cloudflare-one/access-controls/" rel="noopener noreferrer"&gt;Cloudflare Access&lt;/a&gt; or another authentication layer before exposing it to the Internet. When using Access, follow Cloudflare's guidance to &lt;a href="https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/#cloudflare-workers-example" rel="noopener noreferrer"&gt;validate the Access JWT&lt;/a&gt; in the Worker as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run Pi on Cloudflare
&lt;/h2&gt;

&lt;p&gt;If you want to try the same setup, here is the shortest path. Remember that the project has no application-level authentication, so protect a deployed instance before giving it privileged credentials.&lt;/p&gt;

&lt;p&gt;The committed GLM-5.2 configuration requires the Workers Paid plan. Generated app deployment also requires enrollment in the Artifacts beta and an Artifacts namespace named &lt;code&gt;pi-apps&lt;/code&gt;, which you can create in the Cloudflare dashboard.&lt;/p&gt;

&lt;p&gt;Clone the repository and install its dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/harshil1712/pi-on-cf.git
&lt;span class="nb"&gt;cd &lt;/span&gt;pi-on-cf
npm ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the project uses a remote Artifacts binding during local development, authenticate Wrangler first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a local environment file, then add your Cloudflare account ID and an API token with &lt;code&gt;AI Gateway - Read&lt;/code&gt;, &lt;code&gt;AI Gateway - Edit&lt;/code&gt;, and &lt;code&gt;Workers AI - Read&lt;/code&gt; permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_TOKEN=your-api-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the local Worker runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a production deployment, including generated app deployment, configure these Worker secrets. Use the AI Gateway token described above for &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;. &lt;code&gt;WORKERS_DEPLOY_API_TOKEN&lt;/code&gt; needs account-scoped &lt;code&gt;Workers Scripts Read&lt;/code&gt; and &lt;code&gt;Workers Scripts Edit&lt;/code&gt; permissions so the application can create and delete Workers, upload assets, create versions and deployments, and read the account's &lt;code&gt;workers.dev&lt;/code&gt; subdomain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler secret put CLOUDFLARE_ACCOUNT_ID
npx wrangler secret put CLOUDFLARE_API_TOKEN
npx wrangler secret put WORKERS_DEPLOY_API_TOKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the host application with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository includes UI tests running in &lt;code&gt;jsdom&lt;/code&gt; and a separate test suite running inside the Workers runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run lint
npm run typecheck
npm &lt;span class="nb"&gt;test
&lt;/span&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;I started with a simple question after seeing a tweet: can Pi run on Cloudflare?&lt;/p&gt;

&lt;p&gt;What I learned is that Pi's lower-level agent, provider, and session abstractions didn't need its normal terminal host. They needed a set of capabilities around them: durable identity, coordinated state, files, model access, streaming, and a constrained way to execute generated code. Pi's portability let me keep the difficult parts of the agent, including its tool loop, session tree, model abstraction, streaming events, and compaction behaviour.&lt;/p&gt;

&lt;p&gt;Cloudflare supplied the runtime around it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Durable Objects&lt;/strong&gt; turned each Pi session into an isolated, stateful service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite and &lt;code&gt;@cloudflare/shell&lt;/code&gt;&lt;/strong&gt; replaced local transcript and filesystem storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Workers, Worker Bundler, and Artifacts&lt;/strong&gt; created a Worker-native path from generated source to preview and deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The project doesn't imitate a local machine. It treats Workers as the target environment from the beginning. That forced me to be explicit about what the agent can do, and equally explicit about what it cannot do.&lt;/p&gt;

&lt;p&gt;I also have a new appreciation for Pi's design. Making the agent loop and session storage portable is what made this entire experiment possible.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/harshil1712/pi-on-cf" rel="noopener noreferrer"&gt;open source&lt;/a&gt;. If you are experimenting with coding agents on Cloudflare, take a look and let me know what you build. I am especially interested in how you would approach resumable turns, historical workspace snapshots, or multi-user isolation.&lt;/p&gt;

&lt;p&gt;I also recommend looking at &lt;a href="https://developers.cloudflare.com/agents/harnesses/think/" rel="noopener noreferrer"&gt;Project Think&lt;/a&gt;. It is an opinionated harness from Cloudflare whose design is inspired by Pi. It packages Cloudflare-native capabilities including workspace tools, persistence, stream resumption, durable recovery, extensions, and sub-agent support, so less custom infrastructure is required. I personally am building agents using Think.&lt;/p&gt;

&lt;p&gt;If you have questions or feedback, feel free to reach out on &lt;a href="https://www.linkedin.com/in/harshil1712/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or &lt;a href="https://x.com/harshil1712" rel="noopener noreferrer"&gt;X&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/earendil-works/pi" rel="noopener noreferrer"&gt;Pi&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/harshil1712/pi-on-cf" rel="noopener noreferrer"&gt;Pi on Cloudflare source&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/workers/" rel="noopener noreferrer"&gt;Cloudflare Workers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/durable-objects/" rel="noopener noreferrer"&gt;Cloudflare Durable Objects&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/agents/" rel="noopener noreferrer"&gt;Cloudflare Agents SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/ai-gateway/" rel="noopener noreferrer"&gt;Cloudflare AI Gateway&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/dynamic-workers/" rel="noopener noreferrer"&gt;Cloudflare Dynamic Workers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/artifacts/" rel="noopener noreferrer"&gt;Cloudflare Artifacts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>agenticcoding</category>
      <category>pi</category>
    </item>
    <item>
      <title>Making My Astro Site Agent-Ready: An Honest Audit of isitagentready.com</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Sun, 07 Jun 2026 21:37:37 +0000</pubDate>
      <link>https://dev.to/harshil1712/making-my-astro-site-agent-ready-an-honest-audit-of-isitagentreadycom-2f70</link>
      <guid>https://dev.to/harshil1712/making-my-astro-site-agent-ready-an-honest-audit-of-isitagentreadycom-2f70</guid>
      <description>&lt;p&gt;I have built a few SaaS apps and I spend a lot of time thinking about how people find and use them. I track analytics, optimize for search engines, and write descriptive meta tags. But recently I realized a lot of these apps are getting referred by ChatGPT and other AI chat apps. I didn't optimize the sites for this entirely new category of visitor: AI agents.&lt;/p&gt;

&lt;p&gt;Agents are increasingly browsing the web on our behalf — reading documentation, comparing products, summarizing content. But most websites, mine included, are built for human eyes. There's no standard way for a site to tell an agent what it can do, what content it offers, or how to access it in a machine-friendly format.&lt;/p&gt;

&lt;p&gt;Cloudflare launched &lt;a href="https://isitagentready.com" rel="noopener noreferrer"&gt;isitagentready.com&lt;/a&gt;, a scanner that evaluates how "AI-agent-friendly" your website is. I tried it on my personal site built with &lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; and deployed on &lt;a href="https://workers.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare Workers&lt;/a&gt;. I didn't expect a perfect score, but I was curious.&lt;/p&gt;

&lt;p&gt;I pasted in &lt;code&gt;https://harshil.dev&lt;/code&gt;, hit scan, and waited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eight issues. All red.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first reaction was mild panic. My second reaction was more useful: skepticism. Most of these recommendations assume I'm running a SaaS platform with public APIs, authentication flows, and agent-facing tools. I'm not. I'm a developer with a blog and a couple of gated APIs.&lt;/p&gt;

&lt;p&gt;This post is what I actually implemented, what I deliberately skipped, and the small decision framework I used to tell the difference between a meaningful improvement and cargo-cult engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Audit Results
&lt;/h2&gt;

&lt;p&gt;Here's what the scanner found:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnfqxmtksras7q3x5wmja.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnfqxmtksras7q3x5wmja.png" alt="Initial scan with all the options selected" width="800" height="669"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Assumes you're a...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Link response headers (RFC 8288)&lt;/td&gt;
&lt;td&gt;🔴 Fail&lt;/td&gt;
&lt;td&gt;Any site&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Markdown content negotiation&lt;/td&gt;
&lt;td&gt;🔴 Fail&lt;/td&gt;
&lt;td&gt;Any site&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Content Signals in robots.txt&lt;/td&gt;
&lt;td&gt;🔴 Fail&lt;/td&gt;
&lt;td&gt;Any site&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;API catalog (RFC 9727)&lt;/td&gt;
&lt;td&gt;🔴 403&lt;/td&gt;
&lt;td&gt;SaaS with APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;OAuth/OIDC discovery metadata&lt;/td&gt;
&lt;td&gt;🔴 Missing&lt;/td&gt;
&lt;td&gt;SaaS with auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;OAuth Protected Resource Metadata&lt;/td&gt;
&lt;td&gt;🔴 Missing&lt;/td&gt;
&lt;td&gt;SaaS with auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;MCP Server Card&lt;/td&gt;
&lt;td&gt;🔴 403&lt;/td&gt;
&lt;td&gt;SaaS with tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Agent Skills discovery index&lt;/td&gt;
&lt;td&gt;🔴 403&lt;/td&gt;
&lt;td&gt;Agent platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;WebMCP support&lt;/td&gt;
&lt;td&gt;🔴 Missing&lt;/td&gt;
&lt;td&gt;SaaS with actions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three apply to any website. The last six assume infrastructure I simply don't have. But the scanner doesn't know that; it just checks for files and headers. You can select which ones to scan for — I let it scan for all just out of curiosity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Decision Framework
&lt;/h2&gt;

&lt;p&gt;Instead of blindly chasing every red flag, I asked three questions for each recommendation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do I actually have this capability?&lt;/strong&gt; If the scanner wants an MCP Server Card, do I have an MCP server? (No.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it meaningful for a personal blog?&lt;/strong&gt; Would an agent benefit from discovering a gated API catalog? (Not really.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it a quick metadata win?&lt;/strong&gt; Can I add meaningful information with a few lines of code or config? (Yes, for the first three.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the framework I'd recommend to anyone running a similar audit. The goal isn't to turn every light green; it's to provide honest, useful signals to agents that visit your site. Publishing empty discovery documents is worse than not publishing them at all. You're wasting an agent's time and giving it false expectations.&lt;/p&gt;

&lt;p&gt;Once you answer the above questions, you can copy the prompt provided by the site, update it based on your answers, and let your coding agent handle the improvements.&lt;/p&gt;

&lt;h2&gt;
  
  
  What my agent implemented
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Link Headers on the Homepage (RFC 8288)
&lt;/h3&gt;

&lt;p&gt;The scanner found zero &lt;code&gt;Link&lt;/code&gt; headers on my homepage. RFC 8288 defines a standard way to advertise related resources via HTTP headers, so agents don't need to parse HTML to find sitemaps, feeds, or author pages.&lt;/p&gt;

&lt;p&gt;This was a genuine gap. My site has a sitemap, a writings feed, and an about page — things an agent might want to find. But none of them were advertised in the HTTP response.&lt;/p&gt;

&lt;p&gt;The fix was surprisingly simple. Astro's server-side rendering lets you set response headers directly in a page's frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import Layout from "../layouts/Default.astro";

const title = "Harshil Agrawal";
const description = "...";

// RFC 8288 Link headers for agent discovery
Astro.response.headers.set(
  "Link",
  [
    '&amp;lt;/sitemap-index.xml&amp;gt;; rel="describedby"',
    '&amp;lt;/writings&amp;gt;; rel="related"',
    '&amp;lt;/about&amp;gt;; rel="author"',
  ].join(", ")
);
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose three IANA-registered relation types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;describedby&lt;/code&gt;&lt;/strong&gt; for the sitemap — the best machine-readable overview of the site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;related&lt;/code&gt;&lt;/strong&gt; for the writings feed — the primary content stream&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;author&lt;/code&gt;&lt;/strong&gt; for the about page — semantically correct for a personal site&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You could also use &lt;code&gt;service-doc&lt;/code&gt; or &lt;code&gt;api-catalog&lt;/code&gt; if you have those. I don't, so I didn't add them.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Content Signals in robots.txt
&lt;/h3&gt;

&lt;p&gt;The scanner was looking for &lt;a href="https://contentsignals.org/" rel="noopener noreferrer"&gt;Content Signals&lt;/a&gt; — a proposed IETF standard that lets sites declare AI usage preferences via &lt;code&gt;robots.txt&lt;/code&gt; directives. The idea is straightforward: tell agents whether they can use your content for training, search, and input context.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;robots.txt&lt;/code&gt; was minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: *
Allow: /

Sitemap: https://harshil.dev/sitemap-index.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: *
Allow: /
Content-Signal: ai-train=yes, search=yes, ai-input=yes

Sitemap: https://harshil.dev/sitemap-index.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose &lt;code&gt;ai-train=yes&lt;/code&gt; because this is a public blog — the entire point is to share knowledge as widely as possible. &lt;code&gt;search=yes&lt;/code&gt; is obvious, and &lt;code&gt;ai-input=yes&lt;/code&gt; means agents can use the content as context when generating responses. If you run a private or paywalled site, you might choose differently.&lt;/p&gt;

&lt;p&gt;This is a metadata-only change. It's a single line in a text file, but it gives agents a clear signal about your intent. That's the whole point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I deployed the site with these changes, and ran it through the scanner again. The scanner went from red to green on this check immediately.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fngcd0uiw8jznawkmp0ci.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fngcd0uiw8jznawkmp0ci.png" alt="Scanner showing the score of 100 for both Discoverability and Bot Access Control" width="800" height="663"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Markdown Content Negotiation
&lt;/h3&gt;

&lt;p&gt;This was the most interesting check, and the one that took the most actual work.&lt;/p&gt;

&lt;p&gt;The scanner sends requests with &lt;code&gt;Accept: text/markdown&lt;/code&gt; and checks whether your site returns markdown instead of HTML. This matters because markdown is far more token-efficient for LLMs than HTML. A typical HTML page might be 50KB; the same content in markdown might be 8KB. For agents with context window limits, that's a meaningful difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The native solution:&lt;/strong&gt; Cloudflare offers a feature called &lt;a href="https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/" rel="noopener noreferrer"&gt;Markdown for Agents&lt;/a&gt; that does this automatically at the edge. When enabled, Cloudflare intercepts requests with &lt;code&gt;Accept: text/markdown&lt;/code&gt;, fetches the HTML from your origin, strips navigation/scripts/styles, and converts the body to clean markdown. Agents get structured content; browsers get normal HTML. Zero application code required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; It's only available on &lt;strong&gt;Pro, Business, and Enterprise plans&lt;/strong&gt;. I'm on the Free plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DIY approach:&lt;/strong&gt; Since my blog posts are written in MDX, the raw source is already mostly valid markdown. Astro's content collections expose &lt;code&gt;entry.body&lt;/code&gt;, which contains the raw MDX before JSX processing. I can serve this directly when an agent requests markdown.&lt;/p&gt;

&lt;p&gt;Here's what I changed in my blog post route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getEntry, render } from 'astro:content';
import Layout from '../../layouts/Default.astro';

const { id } = Astro.params;
const post = await getEntry('writings', id);

if (!post) {
  return new Response('Not found', { status: 404 });
}

// Content negotiation: return raw markdown when requested
const accept = Astro.request.headers.get('Accept') || '';
if (accept.includes('text/markdown')) {
  return new Response(post.body, {
    status: 200,
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Vary': 'Accept',
    },
  });
}

const { Content } = await render(post);
---

&amp;lt;Layout title={post.data.title} description={post.data.description}&amp;gt;
  &amp;lt;article&amp;gt;
    &amp;lt;Content /&amp;gt;
  &amp;lt;/article&amp;gt;
&amp;lt;/Layout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked great for blog posts, but the scanner tests &lt;strong&gt;multiple pages&lt;/strong&gt; — not just blog posts. The homepage, about page, projects, talks, links, and even the 404 page all need to respond to &lt;code&gt;Accept: text/markdown&lt;/code&gt;. I needed a site-wide solution.&lt;/p&gt;

&lt;h4&gt;
  
  
  A Wrinkle: Prerendering vs. Cost
&lt;/h4&gt;

&lt;p&gt;Blog posts are the most frequently accessed pages on a personal blog. Originally, I used server-side rendering for everything — markdown requests ran &lt;code&gt;getEntry()&lt;/code&gt; and returned &lt;code&gt;post.body&lt;/code&gt;, while HTML requests ran &lt;code&gt;render()&lt;/code&gt; to compile MDX into HTML. This worked, but every blog post request hit the Cloudflare Worker, counting against the free daily request limit.&lt;/p&gt;

&lt;p&gt;The fix was to &lt;strong&gt;prerender blog posts&lt;/strong&gt; back to static HTML (Astro's default behavior) and move markdown negotiation to a &lt;strong&gt;dedicated API endpoint&lt;/strong&gt; that only runs when explicitly requested:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getEntry&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;writings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/markdown; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Vary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The blog post page itself is now prerendered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
import { getCollection, getEntry, render } from 'astro:content';

export const prerender = true;

export async function getStaticPaths() {
  const posts = await getCollection('writings');
  return posts.map((post) =&amp;gt; ({
    params: { id: post.id },
  }));
}

const { id } = Astro.params;
const post = await getEntry('writings', id);
if (!post) {
  return new Response('Not found', { status: 404 });
}

const { Content } = await render(post);
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To wire it all together, a &lt;strong&gt;Cloudflare Transform Rule&lt;/strong&gt; routes markdown requests to the API. This is the key insight: on the Free plan, &lt;strong&gt;static HTML is served from the CDN at no cost&lt;/strong&gt;, while every Worker invocation counts toward your daily limit. By intercepting markdown requests at the edge before they reach the Worker, normal browser traffic bypasses the Worker entirely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browsers&lt;/strong&gt; hit &lt;code&gt;/writings/my-post&lt;/code&gt; — no rule match — static HTML from the CDN (free)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; hit &lt;code&gt;/writings/my-post&lt;/code&gt; with &lt;code&gt;Accept: text/markdown&lt;/code&gt; — rule matches — rewrites to &lt;code&gt;/api/markdown/my-post&lt;/code&gt; internally, API returns raw MDX&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule uses Cloudflare's Rules Language. Note that &lt;code&gt;http.request.headers["accept"]&lt;/code&gt; returns an &lt;code&gt;Array&amp;lt;String&amp;gt;&lt;/code&gt;, so you must use &lt;code&gt;any()&lt;/code&gt; with wildcard index &lt;code&gt;[*]&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expression:&lt;/strong&gt; &lt;code&gt;starts_with(http.request.uri.path, "/writings/") and any(http.request.headers["accept"][*] contains "text/markdown")&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite path:&lt;/strong&gt; &lt;code&gt;concat("/api/markdown/", substring(http.request.uri.path, 10))&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This rewrite is internal — the client never sees the &lt;code&gt;/api/markdown/&lt;/code&gt; URL. The agent gets &lt;code&gt;text/markdown&lt;/code&gt;, the browser gets &lt;code&gt;text/html&lt;/code&gt;, and only markdown-requesting agents pay the Worker cost.&lt;/p&gt;

&lt;h4&gt;
  
  
  Extending Markdown Negotiation to Every Other Page
&lt;/h4&gt;

&lt;p&gt;Astro's frontmatter syntax makes early returns tricky. A page with &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags can't simply &lt;code&gt;return new Response()&lt;/code&gt; in frontmatter without confusing Astro's compiler. After some trial and error with the coding agent, I settled on a middleware pattern for the remaining pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineMiddleware&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:middleware&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MarkdownResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../utils/markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;MarkdownResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AstroGlobal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wantsMarkdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AstroGlobal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/markdown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MarkdownResponse&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MarkdownResponse&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/markdown; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Vary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each page now constructs a markdown string from its data, then throws a &lt;code&gt;MarkdownResponse&lt;/code&gt; if the client requested markdown. The middleware catches it and returns the proper HTTP response.&lt;/p&gt;

&lt;p&gt;For example, the projects page generates a markdown list from its data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// ... fetch projects data ...

let markdownBody = `# Projects\n\nA collection of things I've built, shipped, and open-sourced.\n\n`;

for (const project of allProjects) {
  const tags = project.tags?.length ? ` (${project.tags.join(', ')})` : '';
  markdownBody += `- [${project.title}](${project.link})${tags} — ${project.description}\n`;
}

if (wantsMarkdown(Astro)) throw new MarkdownResponse(markdownBody);
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every page follows this pattern — homepage, about, projects, talks, links, and even the 404 page. Each one constructs a meaningful markdown representation of its content, with proper links and structure, then throws &lt;code&gt;MarkdownResponse&lt;/code&gt; when &lt;code&gt;Accept: text/markdown&lt;/code&gt; is present.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; The raw MDX body for blog posts is about 95% valid markdown, but it may contain JSX component tags. For other pages, I'm constructing markdown programmatically from the page's data — which is cleaner but requires per-page logic. It's not as clean as Cloudflare's native feature — which strips navigation, footers, and scripts automatically — but it works on the Free plan and provides genuine value to agents on every route.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Skipped (and Why)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  API Catalog (RFC 9727)
&lt;/h3&gt;

&lt;p&gt;The scanner checks for &lt;code&gt;/.well-known/api-catalog&lt;/code&gt; returning &lt;code&gt;application/linkset+json&lt;/code&gt;. The idea is to advertise your APIs with link relations like &lt;code&gt;service-desc&lt;/code&gt; (OpenAPI spec), &lt;code&gt;service-doc&lt;/code&gt; (documentation), and &lt;code&gt;status&lt;/code&gt; (health endpoint).&lt;/p&gt;

&lt;p&gt;My site has no public APIs. Creating a JSON catalog was unnecessary ceremony. If I ever add a proper API surface with multiple public endpoints, OpenAPI specs, and documentation, I'll add a catalog. Until then, an empty catalog or a catalog with one entry is just noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  OAuth/OIDC Discovery &amp;amp; Protected Resource Metadata
&lt;/h3&gt;

&lt;p&gt;These checks look for &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt; and &lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt;. They're designed for sites with OAuth-protected APIs that agents need to authenticate against.&lt;/p&gt;

&lt;p&gt;My only "protected" endpoint is &lt;code&gt;/generate-og&lt;/code&gt;, which uses an internal token, not OAuth. There are no public APIs requiring agent authentication, so OAuth discovery metadata would be pure theater.&lt;/p&gt;

&lt;h3&gt;
  
  
  MCP Server Card, Agent Skills, WebMCP
&lt;/h3&gt;

&lt;p&gt;These assume you're operating infrastructure specifically designed for AI agent consumption:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP Server Card:&lt;/strong&gt; Advertises a running MCP (Model Context Protocol) server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent Skills:&lt;/strong&gt; Publishes a discovery index of agent tools/skills&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebMCP:&lt;/strong&gt; Exposes browser-side tools via the WebMCP API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't have an MCP server. I don't have agent skills. I don't have site actions that need to be exposed to browser-based agents. Publishing empty stub files for all of these would be actively misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Scan
&lt;/h2&gt;

&lt;p&gt;After implementing the three changes, I deployed the site and re-ran the scanner. All scores were now at 100!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9nb7bdf9p9m5gsghcvu9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9nb7bdf9p9m5gsghcvu9.png" alt="Final scores" width="800" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Link headers (RFC 8288)&lt;/td&gt;
&lt;td&gt;🔴 Fail&lt;/td&gt;
&lt;td&gt;🟢 Pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Markdown negotiation&lt;/td&gt;
&lt;td&gt;🔴 Fail&lt;/td&gt;
&lt;td&gt;🟢 Pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content Signals&lt;/td&gt;
&lt;td&gt;🔴 Fail&lt;/td&gt;
&lt;td&gt;🟢 Pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't cargo-cult scanner recommendations:&lt;/strong&gt; When I first ran the scanner, I opted for all the checks, even though not all of them were relevant. Only run the scans for the categories that are relevant to your website.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Quick metadata wins have absurdly high ROI:&lt;/strong&gt; Adding Link headers took 6 lines of code. Adding Content Signals took 1 line in a text file. Both immediately improved how agents discover and reason about the site. These are the changes to prioritize.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Know your platform:&lt;/strong&gt; Cloudflare's native Markdown for Agents would have saved me from writing custom content negotiation logic, but it's not available on the Free plan. The DIY approach — middleware + per-page markdown generation — works, but it's worth understanding what you're giving up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Be honest about what your site offers:&lt;/strong&gt; Publishing empty discovery documents (API catalogs, OAuth metadata, MCP server cards) is worse than not publishing them. Agents that discover these files expect them to be meaningful. Don't waste their time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prerender blog posts, but keep an escape hatch for agents:&lt;/strong&gt; Astro's prerendering generates static HTML that serves straight from the edge — zero Worker invocations, zero cost. For pages that agents might request in markdown, a separate API endpoint plus a Cloudflare Transform Rule gives you the best of both worlds: static HTML for humans, raw markdown for agents, without adding Worker overhead to normal traffic.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Making your site "agent-ready" doesn't mean implementing every recommendation from every scanner. It means providing honest, useful signals to agents that visit your site, while recognizing the gap between what a scanner demands and what your site actually is.&lt;/p&gt;

&lt;p&gt;For a personal blog, that means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Link headers&lt;/strong&gt; — advertise your machine-readable resources (sitemaps, feeds, author pages)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Signals&lt;/strong&gt; — declare your AI usage preferences in &lt;code&gt;robots.txt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markdown negotiation&lt;/strong&gt; — serve raw markdown when agents request it, or enable Cloudflare's native feature if you're on Pro+&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip everything else&lt;/strong&gt; — unless you actually run the infrastructure the scanner is looking for&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal isn't a perfect score. The goal is to be a good citizen of the agent-accessible web.&lt;/p&gt;

&lt;p&gt;If you run your own site through &lt;a href="https://isitagentready.com" rel="noopener noreferrer"&gt;isitagentready.com&lt;/a&gt;, I'd love to hear what you decided to implement and what you skipped. Hit me up on &lt;a href="https://x.com/harshil1712" rel="noopener noreferrer"&gt;X/Twitter&lt;/a&gt; to share how you're making your sites agent-ready!&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8288" rel="noopener noreferrer"&gt;RFC 8288 - Web Linking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc9727" rel="noopener noreferrer"&gt;RFC 9727 - API Catalog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://contentsignals.org/" rel="noopener noreferrer"&gt;Content Signals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/" rel="noopener noreferrer"&gt;Cloudflare Markdown for Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://isitagentready.com" rel="noopener noreferrer"&gt;isitagentready.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.iana.org/assignments/link-relations/link-relations.xhtml" rel="noopener noreferrer"&gt;IANA Link Relation Types&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>astro</category>
      <category>agents</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Controlling Elgato Key Lights with Voice Agent</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Mon, 18 May 2026 13:48:35 +0000</pubDate>
      <link>https://dev.to/harshil1712/controlling-elgato-key-lights-with-voice-agent-4mgg</link>
      <guid>https://dev.to/harshil1712/controlling-elgato-key-lights-with-voice-agent-4mgg</guid>
      <description>&lt;p&gt;I have an &lt;a href="https://www.elgato.com/us/en/p/light-strip" rel="noopener noreferrer"&gt;Elgato Air Light&lt;/a&gt; sitting on my desk. It's great for video recording and calls. But every time I want to turn it on, adjust the brightness, or change the color temperature, I have to reach for my phone, open the app, and tap through menus. It's a small friction, but it adds up.&lt;/p&gt;

&lt;p&gt;I also had an &lt;a href="https://docs.m5stack.com/en/core/core2" rel="noopener noreferrer"&gt;M5Stack Core2&lt;/a&gt; gathering dust — an ESP32-based device with a built-in microphone, speaker, and touch screen. I kept thinking: what if I could just &lt;em&gt;talk&lt;/em&gt; to it? "Turn on my light." "Make it warmer." "Dim it to 30 percent."&lt;/p&gt;

&lt;p&gt;That's when I thought, why not use the &lt;a href="https://developers.cloudflare.com/agents/" rel="noopener noreferrer"&gt;Cloudflare Agent SDK&lt;/a&gt; to build an agent. I started building my agent with custom functions to handle audio input and output. The M5Stack would connect to the agent deployed on the edge, send the audio chunks, the agent would process this, perform the action, and stream the audio response back to the device. This was working fine, but it was a lot of code, fragile code. If I switched the Text-To-Speech (TTS) or Speech-To-Text(STT) models, I would have to update the code to handle encoding, and decoding. This wasn't fun at all. &lt;/p&gt;

&lt;p&gt;Then in April, 2026, Cloudflare announced &lt;a href="https://www.npmjs.com/package/@cloudflare/voice" rel="noopener noreferrer"&gt;Cloudflare's Voice SDK&lt;/a&gt;. The SDK turns an agent into a real-time voice agent with streaming speech-to-text, text-to-speech, and conversation history. Combine that with &lt;a href="https://developers.cloudflare.com/workers-ai/" rel="noopener noreferrer"&gt;Workers AI&lt;/a&gt; for the LLM and &lt;a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-mesh/" rel="noopener noreferrer"&gt;Cloudflare Mesh&lt;/a&gt; for reaching local devices from the edge, and I had everything I needed.&lt;/p&gt;

&lt;p&gt;In this article, I'll walk you through how I built a voice-controlled smart light system — from the ESP32 firmware to the Worker running on Cloudflare's edge, and all the gotchas I hit along the way. The article will focus more on the Cloudflare stack, and not the device code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;A voice assistant running on the M5Stack Core2 that can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Have natural conversations using streaming speech-to-text and text-to-speech&lt;/li&gt;
&lt;li&gt;Control my Elgato Air Light on the local network — turn it on/off, adjust brightness and color temperature&lt;/li&gt;
&lt;li&gt;Do all processing on Cloudflare's edge — the ESP32 is just a microphone, speaker, and display&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/IdWkWUscJ0g"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Here's the architecture. Click each node to see what it does:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm35izh3ctrd2gtd69a00.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm35izh3ctrd2gtd69a00.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When I say "turn on my light," the LLM recognizes the intent, calls a tool function, which reaches the Elgato light through Cloudflare Mesh — and then speaks back "Done, I've turned on your light."&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before you follow along, here's what you'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;a href="https://docs.m5stack.com/en/core/core2" rel="noopener noreferrer"&gt;M5Stack Core2&lt;/a&gt; (or any ESP32 with mic and speaker)&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://www.elgato.com/us/en/s/lighting" rel="noopener noreferrer"&gt;Elgato Key Light or Air Light&lt;/a&gt; on your local network&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://www.raspberrypi.com/" rel="noopener noreferrer"&gt;Raspberry Pi 3/4/5&lt;/a&gt; (or any Linux machine) on the same local network as the light&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://dash.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare account&lt;/a&gt; with Workers AI enabled&lt;/li&gt;
&lt;li&gt;Familiarity with TypeScript and Arduino/C++&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Voice SDK: &lt;code&gt;withVoice&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;@cloudflare/voice&lt;/code&gt; SDK provides a &lt;code&gt;withVoice&lt;/code&gt; mixin that turns any Cloudflare Agent (Durable Object) into a real-time voice agent. It handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Continuous streaming STT (speech-to-text) via the Flux model&lt;/li&gt;
&lt;li&gt;Sentence-level TTS (text-to-speech) via Deepgram Aura&lt;/li&gt;
&lt;li&gt;Conversation history persistence in SQLite&lt;/li&gt;
&lt;li&gt;Interruption handling (new speech cancels in-progress TTS)&lt;/li&gt;
&lt;li&gt;A WebSocket protocol that clients connect to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where things got exciting for me. The SDK abstracts away so much of the complexity that the core server code is surprisingly compact.&lt;/p&gt;

&lt;p&gt;Here's what a single spoken turn looks like inside the Worker:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favzh3jhygdkuomp5rmql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favzh3jhygdkuomp5rmql.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;routeAgentRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Connection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agents&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withVoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WorkersAIFluxSTT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VoiceTurnContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cloudflare/voice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;streamText&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createWorkersAI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;workers-ai-provider&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VoiceAgentBase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withVoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;audioFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pcm16&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VoiceAgent&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;VoiceAgentBase&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;transcriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WorkersAIFluxSTT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;tts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PCM16TTS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onTurn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceTurnContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workersAi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createWorkersAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;binding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AI&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;workersAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cf/moonshotai/kimi-k2.6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful voice assistant. Keep responses concise.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;abortSignal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;onTurn&lt;/code&gt; method is called whenever the user finishes speaking. It receives the transcript and returns a text stream — the SDK handles converting that text to speech and streaming the audio back. Make sure to append the current transcript to &lt;code&gt;context.messages&lt;/code&gt; when building the message list for the LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  PCM16 TTS: Why I needed a custom class
&lt;/h3&gt;

&lt;p&gt;This was the first gotcha I hit. The built-in &lt;code&gt;WorkersAITTS&lt;/code&gt; class sends &lt;code&gt;{ text, speaker }&lt;/code&gt; to the Deepgram model (it defaults to &lt;code&gt;aura-1&lt;/code&gt;), which outputs MP3 by default. The ESP32 doesn't have an MP3 decoder (or at least what the coding agents told me), so I needed raw PCM16 audio instead.&lt;/p&gt;

&lt;p&gt;The fix: a custom TTS class that calls the &lt;code&gt;aura-2-en&lt;/code&gt; model directly and passes &lt;code&gt;encoding: "linear16"&lt;/code&gt;, &lt;code&gt;sample_rate: 24000&lt;/code&gt;, and &lt;code&gt;container: "none"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PCM16TTS&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ai&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ai&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cf/deepgram/aura-2-en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;luna&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;linear16&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;returnRawResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Chunking audio for the ESP32
&lt;/h3&gt;

&lt;p&gt;TTS generates audio per-sentence. A short sentence like "Hi! How can I help you?" produces ~90KB of PCM16 data. The ESP32 WebSocket library has a maximum frame size (&lt;code&gt;WEBSOCKETS_MAX_DATA_SIZE&lt;/code&gt;), and the device has limited heap (~170KB free). Sending a single 90KB frame works but leaves little headroom.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;afterSynthesize&lt;/code&gt; hook lets me chunk audio into smaller frames before sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUDIO_CHUNK_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;afterSynthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;AUDIO_CHUNK_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;AUDIO_CHUNK_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// returning null tells the SDK we handled sending ourselves&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The WebSocket protocol
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;withVoice&lt;/code&gt; SDK defines a WebSocket protocol between the client and the server. Here's the full message flow:&lt;/p&gt;

&lt;h3&gt;
  
  
  Client → Server
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"type":"hello","protocol_version":1}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;On connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"type":"start_call","preferred_format":"pcm16"}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User taps to start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary PCM16 frames (16kHz, 16-bit, mono)&lt;/td&gt;
&lt;td&gt;Continuously while in call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"type":"end_call"}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User taps to end&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Server → Client
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;welcome&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connection acknowledged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;State changes: &lt;code&gt;idle&lt;/code&gt;, &lt;code&gt;listening&lt;/code&gt;, &lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;speaking&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transcript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Final transcript with &lt;code&gt;role: "user"&lt;/code&gt; or &lt;code&gt;"assistant"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transcript_interim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Partial STT result while user is speaking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transcript_start/delta/end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Streaming LLM response tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audio_config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Audio format info (format, sampleRate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timing info (llm_ms, tts_ms, first_audio_ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary frames&lt;/td&gt;
&lt;td&gt;PCM16 audio during &lt;code&gt;speaking&lt;/code&gt; status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The ESP32 client
&lt;/h2&gt;

&lt;p&gt;The M5Stack Core2 has a built-in microphone, speaker, display, and touch screen. The firmware does the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connects to WiFi, then opens a WebSocket to the Worker&lt;/li&gt;
&lt;li&gt;Sends &lt;code&gt;hello&lt;/code&gt; and waits for &lt;code&gt;welcome&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On touch: sends &lt;code&gt;start_call&lt;/code&gt;, receives the agent's greeting, then begins streaming mic audio as binary PCM16 frames&lt;/li&gt;
&lt;li&gt;Receives status updates, transcripts, and audio — plays audio through the speaker using triple-buffered &lt;code&gt;playRaw()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On touch again: sends &lt;code&gt;end_call&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This part took the most debugging. The ESP32 is a constrained device, and the M5Stack Core2 has some quirks that weren't obvious from the documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha: mic reinit after speaker playback
&lt;/h3&gt;

&lt;p&gt;The M5Stack Core2 has separate I2S buses for the microphone and speaker, but &lt;code&gt;Speaker.playRaw()&lt;/code&gt; disrupts the mic's I2S state. After playback stops, the mic produces silence. This one took me a while to figure out — I kept thinking my WebSocket connection was dropping, but the mic was just... silent.&lt;/p&gt;

&lt;p&gt;The fix: fully tear down and reinitialize the mic after each playback session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;stop_playback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;M5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Speaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;M5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Speaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;is_playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Restart mic — Speaker.playRaw disrupts the mic I2S bus&lt;/span&gt;
    &lt;span class="n"&gt;M5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;mic_cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;M5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;mic_cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sample_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;mic_cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;magnification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;M5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mic_cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;M5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Gotcha: WebSocket Host header
&lt;/h3&gt;

&lt;p&gt;The ESP32 WebSocket library sends &lt;code&gt;Host: hostname:443&lt;/code&gt; in the header, but &lt;code&gt;routeAgentRequest&lt;/code&gt; (which uses &lt;code&gt;partyserver&lt;/code&gt; internally) expects just &lt;code&gt;Host: hostname&lt;/code&gt;. The extra &lt;code&gt;:443&lt;/code&gt; causes routing to fail silently — no error, no log, just a connection that never reaches the Durable Object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; You need to patch the WebSocketsClient library to omit the port when it's 443 or 80.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha: Durable Object path routing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;routeAgentRequest&lt;/code&gt; converts Durable Object binding names to kebab-case for URL routing. The binding &lt;code&gt;VoiceAgent&lt;/code&gt; maps to path &lt;code&gt;/agents/voice-agent/default&lt;/code&gt;, not &lt;code&gt;/agents/VoiceAgent/default&lt;/code&gt;. The coding agents spent an embarrassing amount of time on this one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Greeting on call start
&lt;/h3&gt;

&lt;p&gt;One nice touch: the agent can speak immediately when a call begins by implementing &lt;code&gt;onCallStart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onCallStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;speak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hi! How can I help you?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means &lt;code&gt;start_call&lt;/code&gt; can produce server audio before the user says anything. It makes the experience feel much more natural.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding smart home control: Elgato Air Light via Mesh
&lt;/h2&gt;

&lt;p&gt;Now for the interesting part. I wanted to say "turn on my light" and have the Worker control the Elgato Air light sitting on my local network.&lt;/p&gt;

&lt;h3&gt;
  
  
  The challenge
&lt;/h3&gt;

&lt;p&gt;The Elgato Air Light exposes a REST API on the local network (&lt;code&gt;http://&amp;lt;ip&amp;gt;:9123/elgato/lights&lt;/code&gt;). But the Worker runs on Cloudflare's edge — it can't reach &lt;code&gt;192.168.x.x&lt;/code&gt; directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution: Cloudflare Mesh + VPC Networks
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Why Cloudflare Mesh and not Cloudflare Tunnel?
&lt;/h4&gt;

&lt;p&gt;If you've used Cloudflare before, you might be wondering: why not just use &lt;a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt;? Both connect your private network to Cloudflare, but they solve different problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; (&lt;code&gt;cloudflared&lt;/code&gt;) is designed for publishing specific services to the internet. You configure a public hostname (like &lt;code&gt;light.example.com&lt;/code&gt;), and Tunnel proxies inbound traffic from the internet to your local service. It's great for "I want my app reachable at this URL." But each service needs its own tunnel route, and the Worker can't initiate arbitrary requests to &lt;em&gt;any&lt;/em&gt; local IP — it can only reach the services you've explicitly published.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Mesh&lt;/strong&gt; (formerly WARP Connector) is designed for private network connectivity. A Mesh node advertises CIDR routes, making an entire subnet reachable. With a &lt;a href="https://developers.cloudflare.com/workers-vpc/configuration/vpc-networks/" rel="noopener noreferrer"&gt;VPC Network binding&lt;/a&gt;, your Worker gets a &lt;code&gt;MESH.fetch()&lt;/code&gt; that can reach &lt;em&gt;any&lt;/em&gt; IP and port in the advertised range — no per-service configuration needed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cloudflare Tunnel&lt;/th&gt;
&lt;th&gt;Cloudflare Mesh&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Traffic direction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inbound to origin — clients connect to published services&lt;/td&gt;
&lt;td&gt;Bidirectional — any participant can initiate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Addressing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;By public hostname&lt;/td&gt;
&lt;td&gt;By private IP (every participant gets a Mesh IP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Worker access&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reach specific published services&lt;/td&gt;
&lt;td&gt;Reach any IP/port in the advertised subnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cloudflared&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;warp-cli&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Protocols&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTTP/S, TCP, SSH, RDP, SMB&lt;/td&gt;
&lt;td&gt;TCP, UDP, ICMP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exposing apps to the internet&lt;/td&gt;
&lt;td&gt;Private network connectivity, VPN replacement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For this project, the Worker needs to call the Elgato's local REST API at &lt;code&gt;192.168.x.x:9123&lt;/code&gt; — a private IP that shouldn't be exposed publicly. Mesh gives the Worker outbound access to the entire local subnet with a single binding. If I add more smart devices later, they're automatically reachable too — no new tunnel routes to configure.&lt;/p&gt;

&lt;p&gt;This is the same approach I used in my &lt;a href="https://dev.to/writings/expose-openclaw-with-cloudflare-tunnels-and-workers-vpc"&gt;previous article&lt;/a&gt; to expose OpenClaw to the internet, but this time using Mesh instead of Tunnels.&lt;/p&gt;

&lt;p&gt;The flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker calls env.MESH.fetch("http://192.168.x.x:9123/elgato/lights")
    → Cloudflare routes to Mesh network
    → Mesh node on local LAN receives the request
    → Forwards to Elgato at 192.168.x.x:9123
    → Response flows back the same path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Setting up the Mesh node
&lt;/h3&gt;

&lt;p&gt;Mesh nodes require Linux. I used a Raspberry Pi 4 sitting on the same local network as the Elgato light.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create a Mesh node in the Cloudflare dashboard&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go to &lt;a href="https://dash.cloudflare.com/?to=/:account/mesh" rel="noopener noreferrer"&gt;Networking &amp;gt; Mesh&lt;/a&gt; and select &lt;strong&gt;Add a node&lt;/strong&gt;. Name it (e.g. &lt;code&gt;home-network&lt;/code&gt;) and copy the connector token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Install the WARP client on the Raspberry Pi&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SSH into the Pi and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add Cloudflare's GPG key and repo&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://pkg.cloudflareclient.com/pubkey.gpg &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;lsb_release &lt;span class="nt"&gt;-cs&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; main"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/cloudflare-client.list

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; cloudflare-warp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Register as a Mesh connector and connect&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;warp-cli connector new &amp;lt;YOUR_TOKEN&amp;gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;warp-cli connect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;warp-cli status
&lt;span class="c"&gt;# Should show: Status update: Connected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node should appear as &lt;strong&gt;Online&lt;/strong&gt; in the Mesh dashboard with a Mesh IP assigned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Add a CIDR route&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the Mesh dashboard, go to your node &amp;gt; &lt;strong&gt;Routes&lt;/strong&gt; tab &amp;gt; &lt;strong&gt;Add route&lt;/strong&gt;: &lt;code&gt;192.168.x.0/24&lt;/code&gt;. This tells Cloudflare that this Mesh node can forward traffic to devices on the local &lt;code&gt;192.168.x.x&lt;/code&gt; subnet — including the Elgato light.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Configure NAT/MASQUERADE on the Mesh node&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; By default, traffic from your Worker arrives at the Mesh node with a source IP in the &lt;code&gt;100.96.0.0/12&lt;/code&gt; WARP range. When the Mesh node forwards this to a local device (like your Elgato), that device will try to reply to its default gateway (your router) instead of the Mesh node, causing connection timeouts.&lt;/p&gt;

&lt;p&gt;You need to configure the Mesh node to rewrite the source IP before forwarding to local devices. I cover the exact &lt;code&gt;nftables&lt;/code&gt; commands in the Gotchas section below. However, if your application is running on the same machine as the Mesh node, you don't need to set this up.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Elgato REST API
&lt;/h3&gt;

&lt;p&gt;The Elgato Key Light / Air Light exposes a simple HTTP API on port 9123:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/elgato/lights&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;Get current state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/elgato/lights&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PUT&lt;/td&gt;
&lt;td&gt;Set state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/elgato/accessory-info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;Device info&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The state payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"numberOfLights"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lights"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"brightness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;on&lt;/code&gt;: 1 = on, 0 = off&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;brightness&lt;/code&gt;: 0–100&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;temperature&lt;/code&gt;: 143–344 (mirek scale — 143 = ~7000K cool white, 344 = ~2900K warm white)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Adding tool calling to the voice agent
&lt;/h3&gt;

&lt;p&gt;Now that I had a way to reach the Elgato from the Worker, I needed the LLM to call the right API based on what I say. The &lt;a href="https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling" rel="noopener noreferrer"&gt;Vercel AI SDK&lt;/a&gt; supports tool calling — you define tools with descriptions and parameters, and the LLM decides when to call them based on user intent.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developers.cloudflare.com/workers-ai/models/kimi-k2.6/" rel="noopener noreferrer"&gt;kimi-k2.6 model&lt;/a&gt; on Workers AI supports multi-turn tool calling natively. When you pass tools to &lt;code&gt;streamText&lt;/code&gt;, the SDK:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sends tool definitions to the LLM&lt;/li&gt;
&lt;li&gt;When the LLM returns a tool call, executes the &lt;code&gt;execute&lt;/code&gt; function&lt;/li&gt;
&lt;li&gt;Feeds the result back to the LLM&lt;/li&gt;
&lt;li&gt;The LLM generates a natural language response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;textStream&lt;/code&gt; returned to &lt;code&gt;onTurn&lt;/code&gt; only contains the final spoken text — all the tool calling happens transparently.&lt;/p&gt;

&lt;h4&gt;
  
  
  Wrangler config
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_flags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"nodejs_compat"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-09-21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"migrations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"new_sqlite_classes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"VoiceAgent"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"durable_objects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bindings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"class_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VoiceAgent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VoiceAgent"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ai"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AI"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vpc_networks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MESH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"network_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cf1:network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"remote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ELGATO_IP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192.168.8.187"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Tool definitions
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod/v4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ELGATO_PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9123&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;elgatoUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`http://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ELGATO_IP&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ELGATO_PORT&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;elgatoTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchLight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;RequestInit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MESH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;elgatoUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/elgato/lights&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Light unreachable via Mesh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;putLight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;fetchLight&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getLightState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchLight&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;numberOfLights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;get_light_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Get the current status of the desk light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
      &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchLight&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;

    &lt;span class="na"&gt;turn_light_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Turn the desk light on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
      &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLightState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;light&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;putLight&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;numberOfLights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;

    &lt;span class="na"&gt;turn_light_off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Turn the desk light off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
      &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLightState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;light&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;putLight&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;numberOfLights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;

    &lt;span class="na"&gt;set_light_brightness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Set the desk light brightness (0-100)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;brightness&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLightState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;light&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brightness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;putLight&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;numberOfLights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brightness&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;

    &lt;span class="na"&gt;set_light_temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Set the color temperature (143=cool to 344=warm)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;143&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;344&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;temperature&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLightState&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;light&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;temperature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;putLight&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;numberOfLights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;lights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;temperature&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;onTurn&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stepCountIs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onTurn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VoiceTurnContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workersAi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createWorkersAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;binding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AI&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;workersAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cf/moonshotai/kimi-k2.6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You are a helpful voice assistant that can also control the desk light.
When asked about the light, use the available tools. Keep responses concise and natural.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;elgatoTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;abortSignal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stopWhen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;stepCountIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The LLM handles intent recognition. When I say "make it brighter," the model calls &lt;code&gt;set_light_brightness&lt;/code&gt;. When I say "what's the weather," it just responds normally. No keyword parsing, no intent classification system — the LLM figures it out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;stopWhen: stepCountIs(5)&lt;/code&gt; option gives the model enough room for the tool-call → tool-result → final-answer loop, while preventing an accidental unbounded tool loop. In my Worker, I also log tool-call start/finish and step summaries so Mesh or schema failures are visible in Worker logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's running where
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ESP32 firmware&lt;/td&gt;
&lt;td&gt;M5Stack Core2 on my desk&lt;/td&gt;
&lt;td&gt;Mic input, speaker output, touch UI, WebSocket client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VoiceAgent&lt;/td&gt;
&lt;td&gt;Cloudflare Worker (Durable Object)&lt;/td&gt;
&lt;td&gt;STT, LLM, TTS, tool execution, conversation history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workers AI&lt;/td&gt;
&lt;td&gt;Cloudflare edge&lt;/td&gt;
&lt;td&gt;Flux STT, kimi-k2.6 LLM, Deepgram aura-2-en TTS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mesh node&lt;/td&gt;
&lt;td&gt;Raspberry Pi 4 on local LAN&lt;/td&gt;
&lt;td&gt;WARP connector bridging Cloudflare to local network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elgato Air Light&lt;/td&gt;
&lt;td&gt;Local network (192.168.8.187:9123)&lt;/td&gt;
&lt;td&gt;HTTP API for light control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Gotchas and lessons learned
&lt;/h2&gt;

&lt;p&gt;I hit a lot of issues building this. Here's a summary of everything I ran into, including some I already mentioned above.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The built-in &lt;code&gt;WorkersAITTS&lt;/code&gt; defaults to MP3.&lt;/strong&gt; If your client can't decode MP3, you need a custom TTS class that explicitly requests &lt;code&gt;encoding: "linear16"&lt;/code&gt;. I covered this earlier in the PCM16 TTS section.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;routeAgentRequest&lt;/code&gt; uses kebab-case paths.&lt;/strong&gt; The Durable Object binding &lt;code&gt;VoiceAgent&lt;/code&gt; maps to URL path &lt;code&gt;/agents/voice-agent/default&lt;/code&gt;, not &lt;code&gt;/agents/VoiceAgent/default&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;ESP32 mic needs reinit after speaker playback.&lt;/strong&gt; On the M5Stack Core2, &lt;code&gt;Speaker.playRaw()&lt;/code&gt; disrupts the mic I2S bus. You must call &lt;code&gt;Speaker.end()&lt;/code&gt;, &lt;code&gt;Mic.end()&lt;/code&gt;, then &lt;code&gt;Mic.begin()&lt;/code&gt; to restore it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;WebSocket Host header matters.&lt;/strong&gt; The ESP32 WebSocket library sends &lt;code&gt;Host: hostname:443&lt;/code&gt;, which breaks &lt;code&gt;routeAgentRequest&lt;/code&gt; routing. Patch the library to omit standard ports.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;afterSynthesize&lt;/code&gt; returning null is valid.&lt;/strong&gt; You can use it to chunk large TTS audio into smaller WebSocket frames — just send them yourself via &lt;code&gt;connection.send()&lt;/code&gt; and return &lt;code&gt;null&lt;/code&gt; so the SDK doesn't double-send.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Tool calling needs a bounded multi-step loop.&lt;/strong&gt; Define tools with &lt;code&gt;execute&lt;/code&gt; functions, pass them to &lt;code&gt;streamText&lt;/code&gt;, and use &lt;code&gt;stopWhen: stepCountIs(5)&lt;/code&gt; so the SDK can run the tool-call → execute → feed-result → generate-response loop. The &lt;code&gt;textStream&lt;/code&gt; only yields the final spoken text.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a id="gotcha-mesh-nat"&gt;&lt;/a&gt;&lt;strong&gt;Mesh routing requires NAT/MASQUERADE on the Mesh node.&lt;/strong&gt; If your Worker gets &lt;code&gt;HandshakeTimeoutError&lt;/code&gt; when calling a local device via &lt;code&gt;env.MESH.fetch()&lt;/code&gt;, the issue is asymmetric routing. When a packet arrives from the Worker, its source IP is in the &lt;code&gt;100.96.0.0/12&lt;/code&gt; WARP range. The local device replies to its default gateway (your router), not back to the Mesh node.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-mesh/routes/" rel="noopener noreferrer"&gt;official Cloudflare docs&lt;/a&gt; recommend solving this by either making the Mesh node the subnet's default gateway, or adding a static route on your router that points &lt;code&gt;100.96.0.0/12&lt;/code&gt; to the Mesh node. The coding agent went with a different approach: rewriting the source IP before forwarding to local devices using &lt;code&gt;nftables&lt;/code&gt;:&lt;/p&gt;



&lt;p&gt;On modern Linux systems using &lt;code&gt;nftables&lt;/code&gt; (most newer Raspberry Pi OS versions), add this rule:&lt;br&gt;
&lt;/p&gt;


&lt;/li&gt;

&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# Check which firewall tool is available&lt;/span&gt;
   which nft  &lt;span class="c"&gt;# If this returns a path, use nftables. If not, install iptables.&lt;/span&gt;

   &lt;span class="c"&gt;# Add MASQUERADE rule (replace eth0 with your LAN interface: eth0, wlan0, etc.)&lt;/span&gt;
   &lt;span class="nb"&gt;sudo &lt;/span&gt;nft add table ip nat
   &lt;span class="nb"&gt;sudo &lt;/span&gt;nft add chain ip nat postrouting &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;type &lt;/span&gt;nat hook postrouting priority 100 &lt;span class="se"&gt;\;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
   &lt;span class="nb"&gt;sudo &lt;/span&gt;nft add rule ip nat postrouting oifname &lt;span class="s2"&gt;"wlan0"&lt;/span&gt; iifname &lt;span class="s2"&gt;"CloudflareWARP"&lt;/span&gt; masquerade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To verify this is the issue before fixing it, SSH into your Mesh node and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# This will fail (simulates the Worker's packet path)&lt;/span&gt;
   curl &lt;span class="nt"&gt;--interface&lt;/span&gt; 100.96.0.2 http://&amp;lt;ELGATO_IP&amp;gt;:9123/elgato/lights
   &lt;span class="c"&gt;# Error: Failed to connect / Handshake timeout&lt;/span&gt;

   &lt;span class="c"&gt;# This works (local origin traffic)&lt;/span&gt;
   curl http://&amp;lt;ELGATO_IP&amp;gt;:9123/elgato/lights
   &lt;span class="c"&gt;# Returns: {"numberOfLights":1,...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first &lt;code&gt;curl&lt;/code&gt; fails but the second succeeds, you need the MASQUERADE rule. Make it persistent across reboots by saving the ruleset and loading it at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'table ip nat {
       chain postrouting {
           type nat hook postrouting priority 100;
           oifname "wlan0" iifname "CloudflareWARP" masquerade
       }
   }'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/nftables-mesh-nat.nft

   &lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables-mesh-nat.nft

   &lt;span class="c"&gt;# Persist across reboots (add to crontab)&lt;/span&gt;
   &lt;span class="o"&gt;(&lt;/span&gt;crontab &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"@reboot sleep 10 &amp;amp;&amp;amp; sudo nft -f /etc/nftables-mesh-nat.nft"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; | crontab -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;I started with a simple frustration — reaching for my phone every time I wanted to adjust my desk light. What I ended up with is a voice assistant that runs on an ESP32, processes everything on Cloudflare's edge, and controls local devices through Mesh networking.&lt;/p&gt;

&lt;p&gt;The stack that made this possible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;@cloudflare/voice SDK&lt;/strong&gt; — handles the hard parts of real-time voice (STT, TTS, conversation state, interruption)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers AI&lt;/strong&gt; — LLM with tool calling for intent recognition&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Mesh&lt;/strong&gt; — bridges the gap between the edge and my local network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel AI SDK&lt;/strong&gt; — clean tool calling abstraction on top of Workers AI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest surprises were the ESP32 quirks (mic reinit after speaker, WebSocket Host header) and the Mesh NAT issue. None of these were documented anywhere, so I hope this article saves you some debugging time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;There's a lot more I want to do with this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add more devices — I have other smart lights on my local network that I'd love to control by voice&lt;/li&gt;
&lt;li&gt;Improve the ESP32 experience — a proper UI on the display showing conversation state and light status&lt;/li&gt;
&lt;li&gt;Experiment with wake word detection instead of the touch-to-talk button&lt;/li&gt;
&lt;li&gt;Try different LLM models as Workers AI adds more options. Kimi K2.6 is excellent, but a bit overkill for this. I might try with smaller models like Granite 4.0 or others from Workers AI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building something similar, or run into any issues, feel free to hit me up on &lt;a href="https://x.com/harshil1712" rel="noopener noreferrer"&gt;X (Twitter)&lt;/a&gt; or &lt;a href="https://linkedin.com/in/harshil1712" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;. I'd love to hear about what you're building with the Voice SDK and Mesh. I also co-authored a book - &lt;a href="https://www.amazon.de/-/en/Building-Virtual-Assistant-Raspberry-Voice-Controlled/dp/B0FDK7SPMD/ref=sr_1_1" rel="noopener noreferrer"&gt;Building a Virtual Assistant with Raspberry Pi&lt;/a&gt; that will help you learn how to build an offline first virtual assistant!&lt;/p&gt;

</description>
      <category>agents</category>
      <category>iot</category>
      <category>nlp</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Migrating from Astro 5 to Astro 6: A Real-World Breakdown 📖</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Sun, 03 May 2026 13:05:28 +0000</pubDate>
      <link>https://dev.to/harshil1712/migrating-from-astro-5-to-astro-6-a-real-world-breakdown-2d0c</link>
      <guid>https://dev.to/harshil1712/migrating-from-astro-5-to-astro-6-a-real-world-breakdown-2d0c</guid>
      <description>&lt;p&gt;I have been meaning to upgrade &lt;a href="https://harshil.dev" rel="noopener noreferrer"&gt;my personal site&lt;/a&gt; to &lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; 6 for a while. The release notes sat in my open tabs for weeks, and every time I sat down to do it, I found an excuse to work on something else. This week, I finally ran out of excuses. I carved out an afternoon, ran &lt;code&gt;npx @astrojs/upgrade&lt;/code&gt;, crossed my fingers, and expected a smooth ride.&lt;/p&gt;

&lt;p&gt;The dev server crashed immediately with a cryptic error about a missing &lt;code&gt;tailwindcss&lt;/code&gt; package.&lt;/p&gt;

&lt;p&gt;I stared at the error for a minute. Then I did what any reasonable developer would do in 2026 — I looped in an AI coding agent and told it to help me fix everything.&lt;/p&gt;

&lt;p&gt;This post is the field guide I wish I had, and also a reminder that AI agents are incredibly helpful but dangerously confident. You still need to know what you're doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I started with
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Astro 5.x (various patch releases)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@astrojs/cloudflare&lt;/code&gt; v12.x&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@astrojs/tailwind&lt;/code&gt; v5.x&lt;/li&gt;
&lt;li&gt;Legacy content collections (&lt;code&gt;src/content/config.ts&lt;/code&gt;, &lt;code&gt;type: 'content'&lt;/code&gt;/&lt;code&gt;'data'&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://expressive-code.com/" rel="noopener noreferrer"&gt;&lt;code&gt;astro-expressive-code&lt;/code&gt;&lt;/a&gt; v0.38.x&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;astro-icon&lt;/code&gt; v1.1.5&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I ended up with
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Astro 6.2&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@astrojs/cloudflare&lt;/code&gt; v13&lt;/li&gt;
&lt;li&gt;Tailwind CSS v4 (&lt;code&gt;@tailwindcss/vite&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;astro-expressive-code&lt;/code&gt; v0.41.7&lt;/li&gt;
&lt;li&gt;A custom inline-SVG icon component (replacing &lt;code&gt;astro-icon&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Error 1: &lt;code&gt;Cannot find package 'tailwindcss'&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Cannot find package &lt;span class="s1"&gt;'tailwindcss'&lt;/span&gt; imported from
.../node_modules/@astrojs/tailwind/dist/index.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@astrojs/tailwind&lt;/code&gt; v6 was installed by the upgrade command, but its &lt;code&gt;package.json&lt;/code&gt; only lists &lt;code&gt;tailwindcss&lt;/code&gt; as a &lt;strong&gt;peer dependency&lt;/strong&gt; — it does not bundle it. Since &lt;code&gt;tailwindcss&lt;/code&gt; wasn't in my &lt;code&gt;dependencies&lt;/code&gt;, Node couldn't resolve it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The agent's diagnosis
&lt;/h3&gt;

&lt;p&gt;I pasted the error into the agent and asked what was going on. It diagnosed the peer dependency issue immediately and confidently moved to PostCSS. It added &lt;code&gt;postcss.config.mjs&lt;/code&gt;, &lt;code&gt;autoprefixed&lt;/code&gt; and the &lt;code&gt;@tailwind&lt;/code&gt; directives. The site was functional again, but the apporach was completely off.&lt;/p&gt;

&lt;p&gt;I had to ask the agent to stick with TailwindCSS and migrate to PostCSS. I asked the agent to check the Astro docs instead of guessing. The agent then used the tools and came up with two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Stay on Tailwind 3 (simplest)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Install &lt;code&gt;tailwindcss@3&lt;/code&gt; alongside the integration and keep everything as-is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;tailwindcss@3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;astro.config.mjs&lt;/code&gt; stays the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tailwind&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@astrojs/tailwind&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="nl"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;tailwind&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the path Astro docs recommend for legacy Tailwind 3 projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: Upgrade to Tailwind 4&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Astro 5.2+ includes an &lt;code&gt;astro add tailwind&lt;/code&gt; command that installs the official Vite plugin (&lt;code&gt;@tailwindcss/vite&lt;/code&gt;), which is the new recommended way to use Tailwind 4 in Astro.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx astro add tailwind
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sets up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;@tailwindcss/vite&lt;/code&gt; in your Vite plugins (via &lt;code&gt;astro.config.mjs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/styles/global.css&lt;/code&gt; with &lt;code&gt;@import "tailwindcss";&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Removes the need for &lt;code&gt;tailwind.config.mjs&lt;/code&gt; — v4 uses CSS-based configuration instead&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you go this route, you can then remove &lt;code&gt;@astrojs/tailwind&lt;/code&gt; entirely and follow &lt;a href="https://tailwindcss.com/docs/upgrade-guide" rel="noopener noreferrer"&gt;Tailwind's v4 upgrade guide&lt;/a&gt; to migrate your custom theme to CSS variables.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened next: The correct apporach
&lt;/h3&gt;

&lt;p&gt;I instructed the agent to proceed with &lt;strong&gt;Option B&lt;/strong&gt;, running &lt;code&gt;npx astro add tailwind&lt;/code&gt; to get the proper Vite plugin set up, then migrated the custom theme into the new &lt;code&gt;@theme&lt;/code&gt; block in &lt;code&gt;global.css&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f97316&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ea580c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-light&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;249&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;115&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fdba74&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@variant&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dark&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If you want to keep Tailwind 3:&lt;/strong&gt; use Option A above and let &lt;code&gt;@astrojs/tailwind&lt;/code&gt; do the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you want Tailwind 4:&lt;/strong&gt; use Option B and the official Vite plugin. Don't manually wire PostCSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error 2: &lt;code&gt;LegacyContentConfigError&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;With Tailwind sorted, I restarted the dev server feeling optimistic. Then the next error hit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[LegacyContentConfigError] Found legacy content config file in
"src/content/config.ts". Please move this file to
"src/content.config.ts" and ensure each collection has a loader defined.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;Astro 5 introduced the &lt;a href="https://docs.astro.build/en/guides/content-collections/" rel="noopener noreferrer"&gt;Content Layer API&lt;/a&gt;, but kept automatic backwards compatibility for old collections. Astro 6 removed that safety net entirely. My &lt;code&gt;src/content/config.ts&lt;/code&gt; with &lt;code&gt;type: 'content'&lt;/code&gt; and &lt;code&gt;type: 'data'&lt;/code&gt; was no longer valid.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;The agent handled this migration competently. It moved the file, rewrote the imports, and configured the loaders:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCollection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro:content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/loaders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/[^_]*.mdx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src/content/writings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/content/projects/projects.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;// not z.array(...)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collections&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File location:&lt;/strong&gt; &lt;code&gt;src/content/config.ts&lt;/code&gt; → &lt;code&gt;src/content.config.ts&lt;/code&gt; (project root inside &lt;code&gt;src/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;z&lt;/code&gt; import:&lt;/strong&gt; &lt;code&gt;astro:content&lt;/code&gt; → &lt;code&gt;astro/zod&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;type&lt;/code&gt; removed:&lt;/strong&gt; No more &lt;code&gt;type: 'content'&lt;/code&gt; or &lt;code&gt;type: 'data'&lt;/code&gt;. You explicitly declare a &lt;code&gt;loader&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data collections:&lt;/strong&gt; If you were using &lt;code&gt;type: 'data'&lt;/code&gt; with a JSON file, the new &lt;code&gt;file()&lt;/code&gt; loader returns one entry per top-level object. The schema should be &lt;code&gt;z.object(...)&lt;/code&gt; (one item), not &lt;code&gt;z.array(...)&lt;/code&gt; (the whole file).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;slug&lt;/code&gt; → &lt;code&gt;id&lt;/code&gt;:&lt;/strong&gt; In the old API, &lt;code&gt;entry.slug&lt;/code&gt; was auto-derived. In the new API, &lt;code&gt;entry.id&lt;/code&gt; is the identifier. My URLs needed updating:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Before
href={`/writings/${post.slug}`}
// After
href={`/writings/${post.id}`}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's where the agent overstepped. It told me that since my posts live in &lt;code&gt;folder/index.mdx&lt;/code&gt; structures, &lt;code&gt;glob()&lt;/code&gt; would generate IDs from file paths, producing something like &lt;code&gt;migrating-astro-5-to-astro-6/index&lt;/code&gt;. It suggested I strip the suffix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;href={`/writings/${post.id.replace(/\/index$/, '')}`}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Something about that felt off. Every post in my &lt;code&gt;writings&lt;/code&gt; collection already has a &lt;code&gt;slug&lt;/code&gt; field in its frontmatter, and I had a hunch Astro would use that as the entry &lt;code&gt;id&lt;/code&gt;. I pushed back and asked the agent to verify against Astro's documentation.&lt;/p&gt;

&lt;p&gt;After checking the docs, it couldn't actually confirm that &lt;code&gt;/index&lt;/code&gt; gets appended when &lt;code&gt;slug&lt;/code&gt; is present in frontmatter. And in practice, &lt;code&gt;post.id&lt;/code&gt; was already &lt;code&gt;migrating-astro-5-to-astro-6&lt;/code&gt; and never &lt;code&gt;migrating-astro-5-to-astro-6/index&lt;/code&gt;. The &lt;code&gt;.replace()&lt;/code&gt; was unnecessary for my setup, and the simple &lt;code&gt;post.id&lt;/code&gt; works perfectly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; always verify agent output against your own code and build output. The agent is fast, but you are the one who has to ship it.&lt;/p&gt;

&lt;p&gt;So far, so good. The agent was saving me hours. Then it got cocky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error 3: &lt;code&gt;Property 'runtime' does not exist on type 'Locals'&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Content collections were fixed. I was ready to see the site render. But the Cloudflare adapter had other plans.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2339&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Property&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;runtime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="nx"&gt;does&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;exist&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Locals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@astrojs/cloudflare&lt;/code&gt; v13 removed &lt;code&gt;Astro.locals.runtime&lt;/code&gt; entirely. The new documented approach is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudflare:workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MY_KV&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I migrated my API routes to this pattern. But then I hit a runtime error in dev:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;defined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;originating from the Cloudflare Vite plugin's worker runner.&lt;/p&gt;

&lt;p&gt;The agent spent a while suggesting random fixes — clearing caches, reordering imports, checking Vite config — none of which worked. I ended up tracing it myself: &lt;code&gt;@astrojs/cloudflare&lt;/code&gt; v13's dev server runs inside &lt;code&gt;workerd&lt;/code&gt;, and it has an incompatibility with the &lt;code&gt;astro-icon&lt;/code&gt; integration. When &lt;code&gt;astro-icon&lt;/code&gt;'s &lt;code&gt;&amp;lt;Icon&amp;gt;&lt;/code&gt; component was first rendered, the workerd module runner would crash with that cryptic &lt;code&gt;module is not defined&lt;/code&gt; error, breaking every route on the site.&lt;/p&gt;

&lt;p&gt;At this point, the was frustrated. It had already spent more time on this than it planned. It downgraded back to &lt;code&gt;@astrojs/cloudflare&lt;/code&gt; v12 just to get something working.&lt;/p&gt;

&lt;p&gt;But we didn't stop there. The agent suggested replacing &lt;code&gt;astro-icon&lt;/code&gt; entirely with a tiny custom component that inlines SVG paths from &lt;code&gt;@iconify-json/mdi&lt;/code&gt;. Thirty lines of code. No virtual modules. No &lt;code&gt;workerd&lt;/code&gt; compatibility issues. We tried it, switched back to v13, and that was the unlock.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: finish the v13 migration properly
&lt;/h3&gt;

&lt;p&gt;First, update &lt;code&gt;wrangler.toml&lt;/code&gt; to use the v13 entrypoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;main&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"@astrojs/cloudflare/entrypoints/server"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then migrate all code from &lt;code&gt;Astro.locals.runtime.env&lt;/code&gt; to &lt;code&gt;import { env } from 'cloudflare:workers'&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VOTES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloudflare:workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VOTES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For TypeScript, I also had to augment &lt;code&gt;Cloudflare.Env&lt;/code&gt; in &lt;code&gt;src/env.d.ts&lt;/code&gt; with secrets that aren't declared in &lt;code&gt;wrangler.toml&lt;/code&gt; (like &lt;code&gt;YOUTUBE_API_KEY&lt;/code&gt; and &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nx"&gt;Cloudflare&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;YOUTUBE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;PLAYLIST_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The agent didn't know about the &lt;code&gt;wrangler types&lt;/code&gt; command. This command generates the types, which would have prevented the manual addition.&lt;/p&gt;

&lt;p&gt;This is required because &lt;code&gt;import { env } from 'cloudflare:workers'&lt;/code&gt; is typed against the global &lt;code&gt;Cloudflare.Env&lt;/code&gt; interface, not the project-level &lt;code&gt;Env&lt;/code&gt; that &lt;code&gt;wrangler types&lt;/code&gt; generates.&lt;/p&gt;

&lt;p&gt;Finally, remove &lt;code&gt;astro-icon&lt;/code&gt; from &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;astro.config.mjs&lt;/code&gt;, and replace &lt;code&gt;&amp;lt;Icon name="mdi:github" /&amp;gt;&lt;/code&gt; usages with the custom component. Problem solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error 4: &lt;code&gt;astro-expressive-code&lt;/code&gt; peer dependency mismatch
&lt;/h2&gt;

&lt;p&gt;That was the biggest battle. I thought I was in the clear. Then &lt;code&gt;npm run build&lt;/code&gt; reminded me that integrations have their own timelines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;peer astro@"^4.0.0-beta || ^5.0.0-beta || ^3.3.0" from astro-expressive-code@0.38.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;The upgrade command didn't bump &lt;code&gt;astro-expressive-code&lt;/code&gt;, so it still had a peer dependency range that excluded Astro 6.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;astro-expressive-code@0.41.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v0.41.7 officially supports Astro 6.&lt;/p&gt;

&lt;p&gt;This one the agent got right on the first try. I take the wins where I can get them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error 5: &lt;code&gt;Buffer&amp;lt;ArrayBufferLike&amp;gt;' is not assignable to parameter of type 'BodyInit'&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Just when I thought the dependency wars were over, TypeScript had one more surprise for me.&lt;/p&gt;

&lt;p&gt;In my OG image generation endpoint, I had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;screenshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the upgrades, TypeScript started rejecting &lt;code&gt;Buffer&lt;/code&gt; as a &lt;code&gt;Response&lt;/code&gt; body. This wasn't a runtime issue — &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;Puppeteer&lt;/a&gt; still returns a &lt;code&gt;Buffer&lt;/code&gt; — but &lt;code&gt;astro check&lt;/code&gt; (and therefore &lt;code&gt;npm run build&lt;/code&gt;) flags it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Converted to &lt;code&gt;Uint8Array&lt;/code&gt; before passing to &lt;code&gt;Response&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This satisfies both the Workers runtime types and TypeScript's strict checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ (no &lt;code&gt;--legacy-peer-deps&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm run dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ All routes render in &lt;code&gt;workerd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm run build&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm run preview&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;npx @astrojs/upgrade&lt;/code&gt; handles the Astro core bump, but integrations often have their own timeline.&lt;/strong&gt; Always check &lt;code&gt;npm ls&lt;/code&gt; for peer dependency warnings after upgrading.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Content collections migration is unavoidable in v6.&lt;/strong&gt; Astro 5 gave you a grace period. Astro 6 does not. The new &lt;code&gt;loader&lt;/code&gt; API is actually clearer once you get used to it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Adapter upgrades are the riskiest part.&lt;/strong&gt; &lt;code&gt;@astrojs/cloudflare&lt;/code&gt; v13 made major changes to how env bindings work and moved the dev server into &lt;code&gt;workerd&lt;/code&gt;. The upside is your dev environment is now nearly identical to production. The downside is that some integrations (like &lt;code&gt;astro-icon&lt;/code&gt;) aren't yet compatible with &lt;code&gt;workerd&lt;/code&gt;'s module loading.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build != dev.&lt;/strong&gt; My site built successfully long before the dev server worked. The v13 Cloudflare adapter broke only in dev (&lt;code&gt;astro dev&lt;/code&gt;) because of how it runs code inside &lt;code&gt;workerd&lt;/code&gt;. Always test both.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When an integration breaks, check the official docs before inventing a workaround.&lt;/strong&gt; I had already pulled &lt;code&gt;@astrojs/tailwind&lt;/code&gt; and installed &lt;code&gt;tailwindcss&lt;/code&gt; directly, but the agent pointed me to &lt;code&gt;@tailwindcss/vite&lt;/code&gt; — the proper Vite plugin for Tailwind CSS v4. Astro's &lt;code&gt;npx astro add tailwind&lt;/code&gt; command sets this up automatically for v4, which is the supported path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;import { env } from 'cloudflare:workers'&lt;/code&gt; is the new standard.&lt;/strong&gt; It replaces &lt;code&gt;Astro.locals.runtime.env&lt;/code&gt; completely. If you're on v13, embrace it — just remember that &lt;code&gt;wrangler types&lt;/code&gt; generates the &lt;code&gt;Env&lt;/code&gt; interface, but &lt;code&gt;cloudflare:workers&lt;/code&gt; reads from &lt;code&gt;Cloudflare.Env&lt;/code&gt;, so you may need to augment the namespace for secrets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI agents are great teammates, but bad team leads.&lt;/strong&gt; They will suggest wrong approaches confidently, miss root causes, and hallucinate migration details. You need to know enough to push back, verify claims, and direct the strategy.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Upgrading from Astro 5 to 6 is not a single command. The core bump is smooth, but the integrations around it — Tailwind, Cloudflare, expressive-code — each carry their own breaking changes. If I were doing this again, I would:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with the content collections migration (&lt;code&gt;src/content/config.ts&lt;/code&gt; → &lt;code&gt;src/content.config.ts&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Decide on Tailwind 3 vs. 4 before running the upgrade. Use &lt;code&gt;npx astro add tailwind&lt;/code&gt; for v4, or install &lt;code&gt;tailwindcss@3&lt;/code&gt; for legacy v3.&lt;/li&gt;
&lt;li&gt;Go straight to &lt;code&gt;@astrojs/cloudflare&lt;/code&gt; v13. The &lt;code&gt;Astro.locals.runtime&lt;/code&gt; → &lt;code&gt;import { env } from 'cloudflare:workers'&lt;/code&gt; migration is mechanical.&lt;/li&gt;
&lt;li&gt;Test &lt;code&gt;npm run dev&lt;/code&gt;, not just &lt;code&gt;npm run build&lt;/code&gt;. &lt;code&gt;workerd&lt;/code&gt; is stricter than Node.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;workerd&lt;/code&gt; dev server in v13 is a net positive — my local environment now behaves almost exactly like production — but it is unforgiving. If you hit &lt;code&gt;module is not defined&lt;/code&gt; or similar low-level errors, trace which integration is triggering them. Replacing &lt;code&gt;astro-icon&lt;/code&gt; with a custom 30-line SVG component removed an entire class of compatibility issues for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.astro.build/en/guides/upgrade-to/v6/" rel="noopener noreferrer"&gt;Astro 6.0 migration guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.astro.build/en/guides/content-collections/" rel="noopener noreferrer"&gt;Content Layer API documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.astro.build/en/guides/integrations-guide/cloudflare/" rel="noopener noreferrer"&gt;Cloudflare adapter for Astro&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tailwindcss.com/docs/upgrade-guide" rel="noopener noreferrer"&gt;Tailwind CSS v4 upgrade guide&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're planning this migration yourself, I'd love to hear how it goes. Feel free to hit me up on &lt;a href="https://x.com/harshil1712" rel="noopener noreferrer"&gt;X/Twitter&lt;/a&gt; if you hit a wall I didn't cover, or if you found a cleaner fix for any of these errors. Stay tuned for more write-ups like this.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devjournal</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Implementing localization to your Svelte App: A step-by-step guide</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Mon, 24 Jun 2024 22:23:15 +0000</pubDate>
      <link>https://dev.to/harshil1712/implementing-localization-to-your-svelte-app-a-step-by-step-guide-4cf</link>
      <guid>https://dev.to/harshil1712/implementing-localization-to-your-svelte-app-a-step-by-step-guide-4cf</guid>
      <description>&lt;p&gt;In today's global market, making your web application accessible to a diverse audience is crucial. Localization enables apps to adapt to different languages and cultural contexts, enhancing user experience.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tolgee.io"&gt;Tolgee&lt;/a&gt; simplifies localization with its open-source i18n tool, combining a localization platform and SDKs. It provides features like in-context translation and automatic screenshot generation, making translation easy for developers and translators. Svelte is an open-source frontend framework that compiles components into small, performant JavaScript modules.&lt;/p&gt;

&lt;p&gt;In this tutorial, you'll learn how to implement localization in your Svelte app using Tolgee. Let's get started!&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;This tutorial assumes that you have familiarity with JavaScript. Knowledge of Svelte is not required but is good to have. You will also need the following, to follow the tutorial.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js: Install &lt;a href="https://nodejs.org/"&gt;Node.js&lt;/a&gt; on your machine if you don’t already have it installed. Use a node version manager tool like &lt;a href="https://github.com/nvm-sh/nvm"&gt;nvm&lt;/a&gt; to install Node.js.&lt;/li&gt;
&lt;li&gt;Tolgee account: You will be using Tolgee to implement localization. Hence, if you don’t have an account, please &lt;a href="https://app.tolgee.io/sign_up?utm_source=svelte-integration-blog"&gt;sign-up here&lt;/a&gt; to create an account.&lt;/li&gt;
&lt;li&gt;Git: Install Git for your machine. You will use Git for cloning the repo and version management.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1 - Setting up the Svelte App
&lt;/h2&gt;

&lt;p&gt;For this tutorial, you will use the &lt;a href="https://github.com/harshil1712/svelte-tolgee-example"&gt;Svelte Tolgee example project&lt;/a&gt;. This project is a personal blogging website built using &lt;a href="https://tailwindcss.com/"&gt;TailwindCSS&lt;/a&gt;. If you want to create your own Svelte app, follow the official &lt;a href="https://svelte.dev/docs/introduction"&gt;Svelte documentation&lt;/a&gt;. While following the steps further down this tutorial, make sure to adapt to your app.&lt;/p&gt;

&lt;p&gt;To clone the project, run the following command in your terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/harshil1712/svelte-tolgee-example.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, navigate into the project directory and run the following command to install the required dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;svelte-tolgee-example
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now that you have all the required dependencies installed, run the development server and check out the project. To start the development server, execute the following command in your terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On your browser, navigate to &lt;code&gt;http://localhost:5137&lt;/code&gt;. You will see the sample app up and running.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fywvgptfod8mp0yovzpnh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fywvgptfod8mp0yovzpnh.png" alt="Screenshot of the home page of the app listing two blog articles" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 - Getting started with Tolgee
&lt;/h2&gt;

&lt;p&gt;In the previous step you scaffolded the Svelte project. In this step you will learn how to get started with Tolgee to integrate it in your Svelte project.&lt;/p&gt;

&lt;p&gt;If you don’t have a Tolgee account, create one. After you sign-up/sign in for the first time, you will be presented with a demo project. While that is a good place to explore the platform in depth, for this tutorial, you will create a new project.&lt;/p&gt;

&lt;p&gt;To create a new project, click on the &lt;strong&gt;+ Add Project&lt;/strong&gt; button. Next, enter the name of your project in the &lt;strong&gt;Name&lt;/strong&gt; field and select the translation languages you. This tutorial implements English (en) and German (de). But you can select other languages as well.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8t8bhj9ywkrwe9fgvc8d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8t8bhj9ywkrwe9fgvc8d.png" alt="Screenshot of the Projects page on the Tolgee platform" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwsbtelxa9znz2pju7u8s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwsbtelxa9znz2pju7u8s.png" alt="Screenshot of the Create Project page on the Tolgee platform" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now that you have the project setup on Tolgee, the next step is to generate the API key. This API key will allow your application to interact with the Tolgee platform. To generate the API key, select &lt;strong&gt;Integrate&lt;/strong&gt; from the left sidebar menu. Next, under the &lt;strong&gt;Choose your weapon&lt;/strong&gt; section, select &lt;strong&gt;&lt;em&gt;Svelte&lt;/em&gt;&lt;/strong&gt;. Click on the dropdown menu under the &lt;strong&gt;Select API key&lt;/strong&gt; section, and select &lt;strong&gt;&lt;em&gt;Create new +&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr6q2ywp66r390nvcywo5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr6q2ywp66r390nvcywo5.png" alt="Screenshot of the Integration page on the Tolgee platform" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Enter the name for your API key in the &lt;strong&gt;Description&lt;/strong&gt; field. Set the &lt;strong&gt;Expiration&lt;/strong&gt; to &lt;strong&gt;&lt;em&gt;Never expires&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; For the purpose of this tutorial, the Expiration is set to Never expires. Please make sure you are following the best security practices and have set a proper expiration time for the API key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next, under &lt;strong&gt;Scopes&lt;/strong&gt;, select &lt;strong&gt;&lt;em&gt;Admin&lt;/em&gt;&lt;/strong&gt;. This will give you all the permissions. Click on Save, and your API key will be generated. Scroll down to the Setup your environment (with SvelteKit) section. Copy the URL and the API Key. You will need these in the next step.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Before setting up the API key for production, carefully read and understand Scopes. You don’t want to give everyone the admin access.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcldsclkkfq11flgm4r1u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcldsclkkfq11flgm4r1u.png" alt="Creating an API Key" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 - Integrating Tolgee in the Svelte app
&lt;/h2&gt;

&lt;p&gt;In the previous step, you configured the Tolgee platform by adding a new project and generating an API key. In this step, you will integrate Tolgee in your Svelte app.&lt;/p&gt;

&lt;p&gt;First, at the root of your project, create a &lt;code&gt;.env.development.local&lt;/code&gt; file. Paste the URL and the API key you copied in the previous step. Your file should contain the content as shown below, where &lt;code&gt;&amp;lt;YOUR_TOLGEE_API_KEY&amp;gt;&lt;/code&gt; is the API key you generated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VITE_TOLGEE_API_URL=https://app.tolgee.io
VITE_TOLGEE_API_KEY=&amp;lt;YOUR_TOLGEE_API_KEY&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, to start using Tolgee and make use of its SDK, you need to install the Tolgee SDK for Svelte. To install this SDK, execute the following command in your terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @tolgee/svelte
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have installed the required SDK and also have the API key to connect to Tolgee. In the next step, you will initialize the Tolgee SDK for your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 - Initializing Tolgee for Svelte
&lt;/h2&gt;

&lt;p&gt;Tolgee comes with a &lt;a href="https://tolgee.io/js-sdk/integrations/svelte/api#tolgeeprovider"&gt;provider component&lt;/a&gt;. This component provides the required context to all the children components it gets wrapped on. You will use this provider component to wrap all the child components with some configurations.&lt;/p&gt;

&lt;p&gt;Open up the &lt;code&gt;src &amp;gt; routes &amp;gt; +layout.svelte&lt;/code&gt; file and paste the following code under the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag. This line of code imports the required methods from the Tolgee SDK.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TolgeeProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Tolgee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DevTools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FormatSimple&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tolgee/svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, to configure Tolgee, add the following code under the import statement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tolgee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tolgee&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DevTools&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FormatSimple&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;defaultLanguage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;availableLanguages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_TOLGEE_API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_TOLGEE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the above code, you initialize Tolgee passing on the DevTools and FormatSimple methods. You also configure the default and available languages for your app. Lastly, you configure your Tolgee credentials.&lt;/p&gt;

&lt;p&gt;Your updated &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag should be as follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Navbar&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../components/Navbar.svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TolgeeProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Tolgee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DevTools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FormatSimple&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tolgee/svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tolgee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Tolgee&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DevTools&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FormatSimple&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;defaultLanguage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_TOLGEE_API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_TOLGEE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;availableLanguages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/script&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now that you have imported the provider and configured it, the next step is to wrap the children components. In your &lt;code&gt;+layout.svelte file&lt;/code&gt;, update the HTML code as follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;TolgeeProvider&lt;/span&gt; &lt;span class="na"&gt;tolgee=&lt;/span&gt;&lt;span class="s"&gt;"{tolgee}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;slot=&lt;/span&gt;&lt;span class="s"&gt;"fallback"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Loading...&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"min-h-screen bg-gray-100"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Navbar&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"container mx-auto px-4 py-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;slot&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/TolgeeProvider&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the above code, you wrap the children components under the &lt;code&gt;&amp;lt;TolgeeProvider&amp;gt;&lt;/code&gt; component. You pass the &lt;code&gt;tolgee&lt;/code&gt; configuration and add a fallback component. While the translation gets loaded, the user will see this fallback component. You can modify this fallback component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 - Implementing localization
&lt;/h2&gt;

&lt;p&gt;In the previous step, you have initialized Tolgee. In this step, you will learn to implement localization using Tolgee. You will update the navigation bar option elements. These elements will render the localized content for the selected language.&lt;/p&gt;

&lt;p&gt;In your &lt;code&gt;src &amp;gt; components &amp;gt; Navbar.svelte&lt;/code&gt; file, import the &lt;a href="https://tolgee.io/js-sdk/integrations/svelte/api#t-component"&gt;T component (Translation component)&lt;/a&gt; from the Tolgee SDK. Paste the following code in the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tolgee/svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, replace the text for anchor tag with &lt;code&gt;&amp;lt;T keyName=”navbar_&amp;lt;VALUE&amp;gt;” defaultValue=”&amp;lt;VALUE&amp;gt;”/&amp;gt;&lt;/code&gt;. Here, &lt;code&gt;&amp;lt;VALUE&amp;gt;&lt;/code&gt; is the title of the page. The &lt;code&gt;T&lt;/code&gt; component takes &lt;code&gt;keyName&lt;/code&gt; and &lt;code&gt;defaultValue&lt;/code&gt; as parameters. The keyName parameter helps to identify the content. For each unique translating content, its value should be unique. The &lt;code&gt;defaultValue&lt;/code&gt; parameter takes the value that should be rendered by default. Your &lt;code&gt;+Navbar.svelte&lt;/code&gt; file should be as follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tolgee/svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-white shadow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"container mx-auto px-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"flex justify-between items-center py-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-xl font-semibold text-gray-900"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;My Blog&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"space-x-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-600 hover:text-gray-900"&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;gt;&amp;lt;T&lt;/span&gt; &lt;span class="na"&gt;keyName=&lt;/span&gt;&lt;span class="s"&gt;"navigation_home"&lt;/span&gt; &lt;span class="na"&gt;defaultValue=&lt;/span&gt;&lt;span class="s"&gt;"Home"&lt;/span&gt;
        &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-600 hover:text-gray-900"&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;gt;&amp;lt;T&lt;/span&gt; &lt;span class="na"&gt;keyName=&lt;/span&gt;&lt;span class="s"&gt;"navigation_about"&lt;/span&gt; &lt;span class="na"&gt;defaultValue=&lt;/span&gt;&lt;span class="s"&gt;"About"&lt;/span&gt;
        &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-600 hover:text-gray-900"&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;gt;&amp;lt;T&lt;/span&gt; &lt;span class="na"&gt;keyName=&lt;/span&gt;&lt;span class="s"&gt;"navigation_contact"&lt;/span&gt; &lt;span class="na"&gt;defaultValue=&lt;/span&gt;&lt;span class="s"&gt;"Contact"&lt;/span&gt;
        &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the development server by executing the following command and navigate to &lt;code&gt;http://localhost:5137&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of the features of Tolgee is in-context translation. In the previous step, you configured the Dev Tools provided by the Tolgee SDK. Press and hold the Option/ALT key on your keyboard and hover over the title. You will observe a red border around the title. If you click on the title holding the Option/ALT key, a Quick Translation window pops up. This is where you can add translation and screenshots.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz9topy4upbthicgptcfi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz9topy4upbthicgptcfi.png" alt="Screenshot of the Svelte app with a red outline on Home" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 - Using in-context Translation
&lt;/h2&gt;

&lt;p&gt;In the previous step, you implemented the &lt;code&gt;T&lt;/code&gt; component. This enabled you to use in-context translation for your app. In this step, you will use in context translation to add translation.&lt;/p&gt;

&lt;p&gt;To get started with in-context translation, press and hold the &lt;strong&gt;Option/ALT&lt;/strong&gt; key and click on &lt;strong&gt;Home&lt;/strong&gt;. It will open up a pop window.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpbrp5w3joz3ttnglzl59.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpbrp5w3joz3ttnglzl59.png" alt="Screenshot of the Translation pop-up window" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Enter the English blog title in the &lt;strong&gt;English&lt;/strong&gt; field. Add the German translation of the title in the &lt;strong&gt;German&lt;/strong&gt; field. Optionally, you can add screenshots in the Screenshot section. Click on &lt;strong&gt;Save&lt;/strong&gt; to save your translation.&lt;/p&gt;

&lt;p&gt;You can view this translation on the Tolgee platform. You can also modify it via the platform or via in-context translation option.&lt;/p&gt;

&lt;p&gt;There might be scenarios where you want to use the platform to manage translations. In the next step, you will learn how to add translation on the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 - Manage Translation on the Platform
&lt;/h2&gt;

&lt;p&gt;To use the platform to manage translation, make sure you are logged into the platform. Next, navigate to the project you created for this application. Click on &lt;strong&gt;Translations&lt;/strong&gt; from the left sidebar. You will see the translation for Home is already available there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxngt4ym4aryomtr449z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxngt4ym4aryomtr449z6.png" alt="Screenshot showing the new created naviation_home key on the Tolgee platform" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To add translation for the About option, click on + on the right corner. Enter &lt;code&gt;navigation_about&lt;/code&gt; in the &lt;strong&gt;Key field&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The key must be unique. It should match the value you pass to the T component.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Enter the English translation in the &lt;strong&gt;English&lt;/strong&gt; field and click on &lt;strong&gt;Save&lt;/strong&gt;. This will generate the key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl0h57coqkq3ern85pg72.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl0h57coqkq3ern85pg72.png" alt="Screenshot of the Create new key window on the Tolgee platform" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To add the German translation for this key, click on &lt;strong&gt;German&lt;/strong&gt; under &lt;strong&gt;navigation_about&lt;/strong&gt;. Tolgee uses various APIs to provide the translations. You can use one of the provided translations or add your own. To use the translation that Tolgee provides, select one of the translations from the &lt;strong&gt;Machine Translation&lt;/strong&gt; section. Click on &lt;strong&gt;Save&lt;/strong&gt;, to save the translation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4w6qugeyxt3dot9d4or2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4w6qugeyxt3dot9d4or2.png" alt="Screenshot of the adding German translation for the navigation_about key window on the Tolgee platform" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You now have translations for Home and About. However, you can’t really view them in your app without changing the language. In the next step, you will add a language switcher for your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8 - Adding a Language Switcher
&lt;/h2&gt;

&lt;p&gt;Irrespective of where the user is based, you should give the user an option to view the content in the language of their choice. Adding a language switcher to your app can help the user select the language from available choices. In this step, you will add a language switcher.&lt;/p&gt;

&lt;p&gt;To add a language switcher, open the &lt;code&gt;Navbar.svelte&lt;/code&gt; file and update the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag with the following code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getTolgee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getTolgeeContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tolgee/svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tolgee&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTolgeeContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;availableLanguages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tolgee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInitialOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;availableLanguages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the above code, you import the required methods. You get the reference to the Tolgee instance you configured in Step 4. Next, you get the list of all the configured available languages for your app. You will use this list to provide options to the users.&lt;/p&gt;

&lt;p&gt;You also need to add a function that listens to the change of language. Paste the following in the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag to add this function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTolgee&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLanguageChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changeLanguage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above subscription function listens for the change in language. When the language changes, it gets updated and the respective translated content is displayed to the user.&lt;/p&gt;

&lt;p&gt;The last step is to show the user a switch component. To add this component, paste the following code under the &lt;code&gt;&amp;lt;div id=”switch”&amp;gt;&lt;/code&gt; tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;select&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{$t.getLanguage()}"&lt;/span&gt; &lt;span class="na"&gt;on:change=&lt;/span&gt;&lt;span class="s"&gt;"{handleLanguageChange}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  {#each availableLanguages as lan}
  &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;{lan}&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
  {/each}
&lt;span class="nt"&gt;&amp;lt;/select&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your finished &lt;code&gt;Navbar.svelte&lt;/code&gt; file should have the following code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getTolgee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getTolgeeContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tolgee/svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tolgee&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTolgeeContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;availableLanguages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tolgee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInitialOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;availableLanguages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTolgee&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;language&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLanguageChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changeLanguage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-white shadow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"container mx-auto px-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"flex justify-between items-center py-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-xl font-semibold text-gray-900"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;My Blog&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"space-x-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-600 hover:text-gray-900"&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;gt;&amp;lt;T&lt;/span&gt; &lt;span class="na"&gt;keyName=&lt;/span&gt;&lt;span class="s"&gt;"navigation_home"&lt;/span&gt; &lt;span class="na"&gt;defaultValue=&lt;/span&gt;&lt;span class="s"&gt;"Home"&lt;/span&gt;
        &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-600 hover:text-gray-900"&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;gt;&amp;lt;T&lt;/span&gt; &lt;span class="na"&gt;keyName=&lt;/span&gt;&lt;span class="s"&gt;"navigation_about"&lt;/span&gt; &lt;span class="na"&gt;defaultValue=&lt;/span&gt;&lt;span class="s"&gt;"About"&lt;/span&gt;
        &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-600 hover:text-gray-900"&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;gt;&amp;lt;T&lt;/span&gt; &lt;span class="na"&gt;keyName=&lt;/span&gt;&lt;span class="s"&gt;"navigation_contact"&lt;/span&gt; &lt;span class="na"&gt;defaultValue=&lt;/span&gt;&lt;span class="s"&gt;"Contact"&lt;/span&gt;
        &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"switch"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;select&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"{$t.getLanguage()}"&lt;/span&gt; &lt;span class="na"&gt;on:change=&lt;/span&gt;&lt;span class="s"&gt;"{handleLanguageChange}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          {#each availableLanguages as lan}
          &lt;span class="nt"&gt;&amp;lt;option&amp;gt;&lt;/span&gt;{lan}&lt;span class="nt"&gt;&amp;lt;/option&amp;gt;&lt;/span&gt;
          {/each}
        &lt;span class="nt"&gt;&amp;lt;/select&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the file with the updated code. Start the development server, if it is not running already. The navigation bar should have a language switcher. Try changing the language, and you should see the respective translated content.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fec7ie1o8f8mzxrdrxj15.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fec7ie1o8f8mzxrdrxj15.png" alt="Screenshot of the Svelte app with English language selected with the language switcher" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqr60b7g5efxjoqd4wicb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqr60b7g5efxjoqd4wicb.png" alt="Screenshot of the Svelte app with German language selected with the language switcher" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Congratulations, you have successfully implemented localization to your Svelte app using Tolgee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Tolgee allows you to seamlessly implement localization to your app. It provides features like in-context translation and SDK that makes it easy for your team to manage translations.&lt;/p&gt;

&lt;p&gt;In this article, you learned about Tolgee and how to integrate it with Svelte. You implemented localization to the navigation bar. Which means, you only scratched the surface! The next step is to implement localization to the rest of your application.&lt;/p&gt;

&lt;p&gt;If you ran into issues while following this tutorial, feel free to hit me up on &lt;a href="http://linkedin.com/in/harshil1712"&gt;LinkedIn&lt;/a&gt;/&lt;a href="http://x.com/harshil1712"&gt;X (formerly Twitter)&lt;/a&gt;. I would be happy to help you. If you have more questions about Tolgee, I encourage you to join the official &lt;a href="https://Tolg.ee/slack"&gt;Slack workspace&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I am looking forward to seeing your localized apps!&lt;/p&gt;

</description>
      <category>svelte</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Airtable automation: How I streamlined submissions to the Developer Showcase</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Thu, 13 Oct 2022 14:28:58 +0000</pubDate>
      <link>https://dev.to/contentful/airtable-automation-how-i-streamlined-submissions-to-the-developer-showcase-d40</link>
      <guid>https://dev.to/contentful/airtable-automation-how-i-streamlined-submissions-to-the-developer-showcase-d40</guid>
      <description>&lt;p&gt;&lt;strong&gt;NOTE:&lt;/strong&gt; This post was &lt;a href="https://www.contentful.com/blog/2022/10/13/airtable-automation-developer-showcase-submissions/" rel="noopener noreferrer"&gt;first published&lt;/a&gt; on the Contentful Blog.&lt;/p&gt;

&lt;p&gt;In April 2022, we launched the &lt;a href="https://www.contentful.com/developers/showcase" rel="noopener noreferrer"&gt;Developer Showcase&lt;/a&gt; to highlight the work of the community. Developers share their projects, apps, videos, or blog posts they’ve created around Contentful. The developer showcase has become the go-to place to see what others are building and get inspired!&lt;/p&gt;

&lt;p&gt;If you’ve worked on a project that you believe can help the community, you can go to the Developer Showcase page, and submit the &lt;a href="https://www.contentful.com/developers/showcase/submit/" rel="noopener noreferrer"&gt;form&lt;/a&gt;. If your project meets the criteria, you get an email from us for the next step. In this step, you share more information about the project and yourself. This information is then used on the Developer Showcase page.&lt;/p&gt;

&lt;p&gt;The process seems straightforward, but there are a lot of repetitive tasks that are being performed behind the scenes. In this tutorial, I’ll share how automated processes using Airtable and a few other integrations  got myself some time back!&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual process and challenges
&lt;/h2&gt;

&lt;p&gt;As mentioned above, if a developer is interested in adding their project to the showcase, they submit a form on the website with initial details. Once the form is submitted, the developer relations (DevRel) team gets a notification over email with the submission details. We then manually enter the details into a Google Sheet.&lt;/p&gt;

&lt;p&gt;Imagine, getting submissions every day, and you have to fill out entries in the Google Sheet manually! Yes, data entry is monotonous and time-consuming. I knew it could be automated and save us time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ntlwddqrkb8yxo0wa5b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ntlwddqrkb8yxo0wa5b.png" alt="Stickman Submit Entry"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the details were entered in the Google Sheet, team members would review the submissions weekly. We’d check and decide if the submissions met the criteria. If the submission was a good fit, the developer would receive an email from us asking them to fill out another form with more details. The information shared via this form would get added to a Contentful space, which was being used to manage the content on the Developer Showcase page (yes, we use Contentful at Contentful!).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhh03hbv8wzqx233mrxn4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhh03hbv8wzqx233mrxn4.png" alt="Stickman Contentful"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The above process was also manual. We had to manually add the relevant information shared via the form to the Contentful space. This again cost us time. I recognized this as another task that could be automated.&lt;/p&gt;

&lt;p&gt;Both the tasks were manual, which meant that the chances of human error were high. There were situations where I would enter the information in the wrong columns when I was in a hurry. This was also becoming monotonous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating the boring parts
&lt;/h2&gt;

&lt;p&gt;I take pride in being lazy! My laziness helps me find solutions that make my tasks joyful. Now that I recognized two tasks that could be automated, I started looking for solutions.&lt;/p&gt;

&lt;p&gt;My first step was to migrate to a spreadsheet-like tool that provided better features than Google Sheets. I was looking for a tool that would allow me to easily create automation workflows, configure different views, and also have an easy-to-use API.  &lt;/p&gt;

&lt;p&gt;Luckily for me, we already use &lt;a href="https://airtable.com/" rel="noopener noreferrer"&gt;Airtable&lt;/a&gt; at Contentful for various projects. I created a base in Airtable for the Developer Showcase which would contain all the details — from the initial form submission details to the final details.&lt;/p&gt;

&lt;p&gt;Everyone in the DevRel team is involved in the showcase. All of us decide if a submission should be added to the Developer Showcase. We handle this by voting. For every member of the team, I created a view that would list all the submissions that they haven’t viewed. Having an individual view made it easy for my team to go in, view the project, leave their thoughts, and vote. It hardly takes them 15 minutes now!&lt;/p&gt;

&lt;p&gt;The migration to Airtable also involved creating automation to handle the form submissions. After setting up the table and the individual views, I created an automation workflow in &lt;a href="https://zapier.com/" rel="noopener noreferrer"&gt;Zapier&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;This workflow would get triggered whenever there was a submission made to the Developer Showcase form. The workflow would then add the new record to the Airtable base, and also send a Slack message to notify us. All I had to do now was to check if the submission was valid and if it was, update the Valid column in Airtable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw78rjuubvnzevq99fa6n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw78rjuubvnzevq99fa6n.png" alt="Zapier"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The above workflow replaced the manual labor of entering the data into Google Sheets. The first task was now automated!&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Airtable to Contentful
&lt;/h2&gt;

&lt;p&gt;The other time-consuming task was entering the final details of the submissions to Contentful, and I wanted to automate it next. Now that we were using Airtable to manage the data, I re-created the template for the final submission form in Airtable. This enabled us to capture the data in Airtable itself. Whenever a developer submitted this form with the details, it now got saved in Airtable. There was no need to create an automation workflow to handle it!&lt;/p&gt;

&lt;p&gt;The data from the final form submission was in Airtable, and not Contentful, yet. To get this data added automatically to Contentful, I created a workflow in Airtable. If you’re using Airtable, you might know that Airtable doesn’t have webhooks (at the time of writing this article). This means that whenever data is created or updated, you can’t trigger external events. &lt;/p&gt;

&lt;p&gt;To overcome this, you can either create automation workflows that run at a certain interval of time, or check for new data and then process that data. A better and faster solution is to use automation within Airtable. It allows you to trigger actions when data is entered or updated.&lt;/p&gt;

&lt;p&gt;I created a workflow in Airtable to handle the above scenario. Every time a developer submitted the final form, this workflow would get triggered. It is out of the scope of this article to walk you through all the steps of creating the automation workflow. But, below I’m sharing the code snippet that populates Contentful with the data.&lt;/p&gt;

&lt;p&gt;Once the form is submitted, the workflow gets triggered. The next step is to create an action that will add the content to Contentful. Airtable doesn’t have an in-built action for Contentful, so there isn’t a no-code or even a low-code solution available just yet. However, it has an action that allows you to run custom JavaScript code. &lt;/p&gt;

&lt;p&gt;While working with this action, I learned that you can’t import npm packages. Hence, using the Contentful Management JavaScript SDK was not possible. Fortunately, Contentful has a REST API to interact with the &lt;a href="https://www.contentful.com/developers/docs/references/content-management-api/" rel="noopener noreferrer"&gt;Management API&lt;/a&gt;! I wrote the following code in the “Run a Script” action. This script populated the Contentful space with the data from Airtable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @ts-ignore&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SPACE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;SPACE_ID&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.contentful.com/spaces/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SPACE_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUTHORLINK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorLink&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUTHOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;author&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENTRY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;entry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linkId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;authorId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;makeCall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer MANAGEMENT_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.contentful.management.v1+json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Contentful-Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checkLinkType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;github&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Github&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;linkedin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LinkedIn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;twitter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Twitter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;youtube&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YouTube&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addAuthorLink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inputConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorLink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;linkType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkLinkType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;makeCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/environments/master/entries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AUTHORLINK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fields&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;internalTitle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;inputConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'s &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;linkType&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;linkType&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}))&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;linkId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;linkId&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addAuthor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;linkId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sys&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linkType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Entry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;link&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;makeCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/environments/master/entries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AUTHOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fields&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;inputConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;inputConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorBio&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;links&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;authorId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; 
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;makeCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/environments/master/entries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ENTRY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fields&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;inputConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectTitle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;inputConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectDesc&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;inputConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectLink&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;author&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sys&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linkType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Entry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authorId&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sys&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linkType&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Entry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FIELD_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
              &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addAuthorLink&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addAuthor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addEntry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Walking through the code
&lt;/h2&gt;

&lt;p&gt;Let’s break down the code to understand what is happening. The first few lines are defining some variables that we need later in the code. This includes the space ID, URL of the Management API, content type IDs, as well as the input object from Airtable.&lt;/p&gt;

&lt;p&gt;We then define a reusable function &lt;code&gt;makeCall&lt;/code&gt; that takes the API endpoint, HTTP method, content type, and the body as the parameters. We use this function to call the Management API.&lt;/p&gt;

&lt;p&gt;An author of a Developer Showcase submission can share social links. These links are displayed on the respective author page, e.g., &lt;a href="https://www.contentful.com/developers/showcase/author/teemu-tammela/" rel="noopener noreferrer"&gt;one of our top contributors Teemu Tammela&lt;/a&gt;. To identify the type of this link, we are using the &lt;code&gt;checkLinkType&lt;/code&gt; function. It returns the type that we have configured in Contentful.&lt;/p&gt;

&lt;p&gt;Next, we define the &lt;code&gt;addAuthorLink&lt;/code&gt; function. This function creates an array of links shared by the author, gets the link type, and adds them to Contentful. It also returns an array of link IDs that get used in the &lt;code&gt;addAuthor&lt;/code&gt; function.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;addAuthor&lt;/code&gt; function adds all the information about the author, as well as adds references to the links. The final result, a new entry of the author, is now available with references to the author’s social media accounts.&lt;/p&gt;

&lt;p&gt;Lastly, the &lt;code&gt;addEntry&lt;/code&gt; function adds the project details and references the author. Since the type of project has not been asked in the form, it is hard-coded. The type is manually updated before the entries are published.&lt;/p&gt;

&lt;p&gt;The last action in this automation workflow is sending a message on Slack. Since Airtable has an built-in app for Slack, it was straightforward to set it up!&lt;/p&gt;

&lt;p&gt;To summarize the above workflow, every time the Airtable form was submitted, the automation would be triggered. It will execute the “Run a script” action, that runs the above code and adds the content to Contentful. Once the content is successfully added, a message is sent on Slack.&lt;/p&gt;

&lt;p&gt;All we have to do now is log in to the Contentful space, review the newly created entries, add the missing assets, and publish the changes!&lt;/p&gt;

&lt;h2&gt;
  
  
  What’s next?
&lt;/h2&gt;

&lt;p&gt;These workflows have saved us a huge amount of time! We don’t have to manually add data, and we can focus more on highlighting the amazing work the community is doing!&lt;/p&gt;

&lt;p&gt;The code currently doesn’t handle images, however. We manually have to add them to Contentful. I couldn’t find a good solution to handle images in Airtable, and I am still on the lookout. If you have worked with images in Airtable and have a solution, please let me know!&lt;/p&gt;

&lt;p&gt;There are a lot of other tasks that can be automated in the whole process. But my experience with automation has taught me one thing — start small and automate the things you do often. It has helped me not over-engineer a simple solution.&lt;/p&gt;

&lt;p&gt;Now that I have more free time, why don’t you share what you’ve created with Contentful? I would love to see it and share it with the community. Also, let me know which Developer Showcase entries you find the most interesting!&lt;/p&gt;

</description>
      <category>automation</category>
      <category>contentful</category>
      <category>airtable</category>
      <category>javascript</category>
    </item>
    <item>
      <title>What is TypeScript and why should you use it?</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Thu, 01 Sep 2022 10:04:30 +0000</pubDate>
      <link>https://dev.to/contentful/what-is-typescript-and-why-should-you-use-it-3bj1</link>
      <guid>https://dev.to/contentful/what-is-typescript-and-why-should-you-use-it-3bj1</guid>
      <description>&lt;p&gt;&lt;strong&gt;NOTE:&lt;/strong&gt; This article originally appeared on the &lt;a href="https://www.contentful.com/blog/2022/08/18/what-is-typescript-and-why-should-you-use-it/"&gt;Contentful Blog&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’ve been learning and using TypeScript for the past year for various projects, and I have been enjoying it. TypeScript provides a great developer experience, and I believe that it has increased my productivity. I don’t have to manually check for errors every time a change gets made, and the inline documentation helps me ship faster. In this article, I will give you an overview of TypeScript, share some of its features, and help you get started.&lt;/p&gt;

&lt;p&gt;Before learning about TypeScript, let’s take a step back and revisit JavaScript. Since the earliest days of the World Wide Web, JavaScript has been used to make websites interactive – handle mouse and keyboard events, validate forms, and so on. With time, the language evolved. You can now use JavaScript to build websites and apps for any platform. It can be used to code both the frontend and backend of your website/app. You can build cross-platform apps using React Native and desktop apps with Electron, and you can even use JavaScript for your next IoT project!&lt;/p&gt;

&lt;p&gt;With these extensive use cases, the complexity in the codebase increased. For smaller projects, using JavaScript is still fine. However, for larger projects, it became difficult to debug the code and catch errors.&lt;/p&gt;

&lt;h1&gt;
  
  
  What is the difference between TypeScript and JavaScript?
&lt;/h1&gt;

&lt;p&gt;TypeScript is a superset of JavaScript. It means that TypeScript provides all the features and functionalities of JavaScript with some added features. TypeScript compiles to JavaScript, which the browser understands. TypeScript provides “type safety” (hence the name!) to JavaScript. It was created by Microsoft and is open source. If you’re interested in contributing or just going through the source code, you can find the repository on &lt;a href="https://github.com/Microsoft/TypeScript"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To understand type safety, consider an example where you define a variable name. In JavaScript, you would use a similar code as below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Harshil&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since this is a variable name and we haven’t defined its type, this variable can have any value. It doesn’t matter if it’s a string, number, boolean, or object. Hence, the below code will execute without errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;

&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Harshi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="na"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Agrawal:
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using TypeScript, you can define the type string for the variable name, as below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Harshil&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you change its value to any other type, the compiler returns an error. Type safety saves a lot of debugging time and helps in keeping the code consistent.&lt;/p&gt;

&lt;h1&gt;
  
  
  Features of TypeScript
&lt;/h1&gt;

&lt;p&gt;TypeScript extends JavaScript and improves the developer experience. It enables developers to add type safety to their projects. Moreover, TypeScript provides various other features, like interfaces, type aliases, abstract classes, function overloading, tuple, generics, etc. Explaining all the features is out of the scope of this article. However, I will present two that I find useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interface
&lt;/h2&gt;

&lt;p&gt;You receive an object when you call any Contentful API. This object response contains the necessary data that you need. Similar to the Contentful API, other APIs also send an object as a response.&lt;/p&gt;

&lt;p&gt;Interfaces get used to ensure that the object contains the required data with the correct data type. This object can be a response or request body or parameters for a function.&lt;/p&gt;

&lt;p&gt;An interface has the following syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;interfaceName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;variableOne&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;variableTwo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using the above syntax, we can create an interface Profile with the properties name and social.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;social&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above interface can be used as follow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;hello&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Find &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; here &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;social&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above is a simple example of interfaces. There are various other functionalities that interfaces provide. You can set optional properties, extend interfaces to add new properties, and more. To learn more about interfaces, refer to the &lt;a href="https://www.typescriptlang.org/docs/handbook/interfaces.html"&gt;TypeScript documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Literal Types
&lt;/h2&gt;

&lt;p&gt;Another feature that I find useful is Literal Types. Though, by themselves, they’re not so useful. But one can combine them into unions, which makes the Literal Types useful.&lt;/p&gt;

&lt;p&gt;The following example demonstrates the syntax of Literal Types.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;social&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;twitter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;harshil1712&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the variable social has the type “twitter.” However, this isn’t useful on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bob&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;){&lt;/span&gt;
    &lt;span class="c1"&gt;//...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hey&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bob&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hey&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Argument of type '"Max"' is not assignable to parameter of type '"Alice" | "Bob"'.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the above function, the second parameter can only take values that have the type of either “Alice” or “Bob.” This helps us in writing functions that only accept a certain set of known values. They can do much more with Literal Types. Read the &lt;a href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types"&gt;official documentation&lt;/a&gt; to learn more.&lt;/p&gt;

&lt;h1&gt;
  
  
  How to use TypeScript?
&lt;/h1&gt;

&lt;p&gt;Now that you have a basic understanding of what TypeScript is, in this section, you will learn how to use TypeScript.&lt;/p&gt;

&lt;p&gt;Browsers don’t understand TypeScript. They understand JavaScript code. Hence, the TypeScript code needs to get compiled into JavaScript, and for that, you need the TypeScript compiler.&lt;/p&gt;

&lt;p&gt;You can install TypeScript globally using the following npm command. The global installation allows you to run the tsc command anywhere from your terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; typescript
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have installed TypeScript, create an index.ts file and add the following code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello, World!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;greeting&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;greeting&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above code declares a variable message and creates a function that logs the message to the console. To run this code, you have to compile it to JavaScript. Run the following command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tsc index.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You’ll observe that the compiler creates an index.js file with the compiled code.&lt;/p&gt;

&lt;p&gt;The TypeScript compiler is flexible and allows you to configure options like the target JavaScript version. At the time of writing this article, the default target is es3.&lt;/p&gt;

&lt;p&gt;To configure the compiler options, you can either pass them with the CLI command or create a tsconfig.json. You can learn more about the various configuration options on the &lt;a href="https://www.typescriptlang.org/tsconfig/"&gt;TSConfig Reference documentation&lt;/a&gt; page.&lt;/p&gt;

&lt;p&gt;If you want to use TypeScript in your next Contentful project, we released &lt;a href="https://www.npmjs.com/package/contentful"&gt;contentful.js&lt;/a&gt; v10 in Beta with enhanced TypeScript support. Check out the &lt;a href="https://www.contentful.com/developers/changelog/#beta-release-contentfuljs-v10-with-enhanced-typescript-support"&gt;changelog&lt;/a&gt; and let us know what you think!&lt;/p&gt;

&lt;h1&gt;
  
  
  Advantages of using TypeScript
&lt;/h1&gt;

&lt;p&gt;TypeScript extends JavaScript, providing a better developer experience. The benefits of using TypeScript over JavaScript include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Static typing – TypeScript comes with optional static typing and a type inference system, which means that a variable, declared with no type may be inferred by TypeScript based on its value.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Object oriented programming – TypeScript supports object-oriented programming concepts like classes, inheritance, etc.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Compile time checks – JavaScript is an interpreted programming language. There is no compilation involved. Hence, the errors get caught during the runtime. Since TypeScript compiles into JavaScript, errors get reported during the compile time rather than the runtime.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Code editor support – IDEs or code editors like VS Code support autocomplete for a TypeScript codebase. They also provide inline documentation and highlight the errors.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use existing packages – You might want to use an npm package that is written in JavaScript. Since TypeScript is a superset of JavaScript, you can import and use that package. Moreover, the TypeScript community creates and maintains type definitions for popular packages that can be utilized in your project. You can learn more about it here.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  What is the best way to learn TypeScript?
&lt;/h1&gt;

&lt;p&gt;TypeScript comes with tons of features, and learning and implementing them all together might get overwhelming. I started learning TypeScript by refactoring my JavaScript codebase to TypeScript one line at a time. This helped me to migrate my codebase to TypeScript with minimal effort and helped me dive deeper into the concepts.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.typescriptlang.org/docs/handbook/intro.html"&gt;TypeScript Handbook&lt;/a&gt; is a great place to learn about TypeScript. It explains the concepts well and contains relevant examples. The handbook is also regularly updated with new features.&lt;/p&gt;

&lt;p&gt;There are also tutorials available that will help you with &lt;a href="https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html"&gt;migrating your JavaScript project&lt;/a&gt; to TypeScript or help you learn about &lt;a href="https://www.typescriptlang.org/docs/handbook/dom-manipulation.html"&gt;DOM manipulation&lt;/a&gt; in TypeScript.&lt;/p&gt;

&lt;p&gt;The Contentful community members have also created apps and tools that can help &lt;a href="https://github.com/marcolink/cf-content-types-generator-app"&gt;generate type declarations for your content types&lt;/a&gt; and &lt;a href="https://www.modelberry.com/"&gt;sync TypeScript with your content model&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you’re looking for your next TypeScript project, why don’t you create an app for your Contentful space with &lt;a href="https://www.contentful.com/developers/docs/extensibility/app-framework/"&gt;App Framework&lt;/a&gt;? The boilerplate is available in TypeScript, where you can learn or brush up your TypeScript knowledge. Happy type checking!&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to automatically manage contributions to open-source projects 🏷️</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Fri, 26 Nov 2021 08:05:18 +0000</pubDate>
      <link>https://dev.to/n8n/how-to-automatically-manage-contributions-to-open-source-projects-4njl</link>
      <guid>https://dev.to/n8n/how-to-automatically-manage-contributions-to-open-source-projects-4njl</guid>
      <description>&lt;p&gt;October is an exciting month, with people across the world looking forward to Halloween or Oktoberfest. But for me, I'm most excited about &lt;a href="https://hacktoberfest.digitalocean.com/"&gt;&lt;strong&gt;Hacktoberfest&lt;/strong&gt;&lt;/a&gt;–the month-long celebration of open-source projects. DigitalOcean has been organizing this event for the past eight years with the goal of encouraging the global tech community to support open-source projects.&lt;/p&gt;

&lt;p&gt;Hacktoberfest provides a great opportunity on one side for individual developers to apply their skills on real-world projects, and on the other side for organizations to extend their projects with the help of contributors.&lt;/p&gt;

&lt;p&gt;However, the popularity of Hacktoberfest also creates spam, unnecessary pull requests, and loads of comments on GitHub issues, which can become overwhelming for the project owners and maintainers.&lt;/p&gt;

&lt;p&gt;Our solution to this problem? &lt;strong&gt;Automation&lt;/strong&gt;! In this tutorial, you will learn how to create a no-code workflow that automatically assigns an open GitHub issue to a contributor interested in working on it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Table of contents
&lt;/h4&gt;

&lt;p&gt;Prerequisites for building the workflow&lt;br&gt;&lt;br&gt;
Workflow for assigning GitHub issues to contributors&lt;br&gt;
&amp;nbsp; &amp;nbsp; 1. Trigger the workflow on GitHub events &lt;br&gt;
&amp;nbsp; &amp;nbsp; 2. Route the workflow on specific events &lt;br&gt;
&amp;nbsp; &amp;nbsp; 3. Handle new GitHub issues &lt;br&gt;
&amp;nbsp; &amp;nbsp; 4. Assign the issue&lt;br&gt;
&amp;nbsp; &amp;nbsp; 5. Handle new comments on GitHub issues &lt;br&gt;
&amp;nbsp; &amp;nbsp; 6. Check if the issue is already assigned&lt;br&gt;
&amp;nbsp; &amp;nbsp; 7. Assign an issue to a contributor &lt;br&gt;
&amp;nbsp; &amp;nbsp; 8. Add a comment to the issue&lt;br&gt;
What's next?&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--z8bCUKBn--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh3.googleusercontent.com/88uNfhZL9pgwWQbszoOlkwzPNWmMkW9pRJRCYYjzQyAzyyChi05EMW92UJHiQYOC-yWyc-cS882JRmgSZir7Q7qyv-rAC_Mg5qOHBEqM3deTt6w8CEZQPAJzXAAFnNA4yolG0tUD%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--z8bCUKBn--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh3.googleusercontent.com/88uNfhZL9pgwWQbszoOlkwzPNWmMkW9pRJRCYYjzQyAzyyChi05EMW92UJHiQYOC-yWyc-cS882JRmgSZir7Q7qyv-rAC_Mg5qOHBEqM3deTt6w8CEZQPAJzXAAFnNA4yolG0tUD%3Ds1600" alt="Workflow for assigning GitHub issues to contributors"&gt;&lt;/a&gt;Workflow for assigning GitHub issues to contributors&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites for building the workflow
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;n8n&lt;/strong&gt;: Sign up for &lt;a href="https://www.n8n.cloud/"&gt;n8n.cloud&lt;/a&gt; to quickly get started or use n8n on your own servers. Refer to the &lt;a href="https://docs.n8n.io/getting-started/installation/"&gt;installation&lt;/a&gt; guide to learn more.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;GitHub account&lt;/strong&gt;: To connect the workflow with your GitHub account, follow the steps mentioned in the &lt;a href="https://docs.n8n.io/credentials/github"&gt;documentation&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Workflow for assigning GitHub issues to contributors
&lt;/h2&gt;

&lt;p&gt;The general idea of &lt;a href="https://n8n.io/workflows/1274"&gt;this workflow&lt;/a&gt; is to assign an issue to the contributor who is interested in working on it. There are four scenarios that you need to consider:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; A contributor creates an issue and wants to work on it.&lt;/li&gt;
&lt;li&gt; A contributor creates a new issue and doesn't want to work on it.&lt;/li&gt;
&lt;li&gt; A contributor is interested in working on an issue reported by another contributor and no one got assigned to it.&lt;/li&gt;
&lt;li&gt; A contributor is interested in working on an issue opened by another contributor, but someone else has already been assigned to it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In popular open-source projects, you usually meet all these scenarios. If you're a project maintainer, you know it can become difficult to manage the contributions efficiently, especially during events like Hacktoberfest.&lt;/p&gt;

&lt;p&gt;The visual builder that n8n provides helps you classify and handle issues automatically. Let's dive into it!&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Trigger the workflow on GitHub events
&lt;/h3&gt;

&lt;p&gt;The workflow should run every time someone opens a  new issue or comments on an issue. For this, add the GitHub Trigger node and configure the following parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Authentication:&lt;/em&gt;&lt;/strong&gt; If you're using n8n.cloud, I suggest you select the OAuth2 authentication method. This option allows you to connect the node with your GitHub account without the need to create credentials. Otherwise, you can learn how to create the credentials from our &lt;a href="https://docs.n8n.io/credentials/github"&gt;documentation&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Repository Owner:&lt;/em&gt;&lt;/strong&gt; Enter your GitHub username.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Repository Name:&lt;/em&gt;&lt;/strong&gt; Enter the name of your repository.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Events:&lt;/em&gt;&lt;/strong&gt; Select &lt;code&gt;issue_comment&lt;/code&gt; and &lt;code&gt;issues&lt;/code&gt; from the dropdown menu.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--d6y_HXr3--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/1V_dcj27abNeaRXwpMZ064kBW0MP8kuIiAiFhIb1qMCy-eaR7B2Zes35bKOAZ4VNuOd2NvwyHrqLjgkP5cFS2snGGWmRIM71OiM2Cn5o7qrpnnt-gmLnilgyT80fO6JVrtobm1SK%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--d6y_HXr3--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/1V_dcj27abNeaRXwpMZ064kBW0MP8kuIiAiFhIb1qMCy-eaR7B2Zes35bKOAZ4VNuOd2NvwyHrqLjgkP5cFS2snGGWmRIM71OiM2Cn5o7qrpnnt-gmLnilgyT80fO6JVrtobm1SK%3Ds1600" alt="Configuration of GitHub Trigger node"&gt;&lt;/a&gt;Configuration of GitHub Trigger node&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Now the workflow will run when a new issue gets created, closed, or edited, or when a comment gets posted, removed, or edited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: If you want to use this workflow for a repository that belongs to an organization, you will have to use the Webhook Trigger node. Refer to the &lt;a href="https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks#setting-up-a-webhook"&gt;GitHub documentation&lt;/a&gt; to learn to add webhooks to your repository.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Route the workflow on specific events
&lt;/h3&gt;

&lt;p&gt;The workflow should continue only when a new issue gets created or a new comment gets posted. To identify these events, add a Switch node and set the following parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Data Type:&lt;/em&gt;&lt;/strong&gt; String&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 1:&lt;/em&gt;&lt;/strong&gt; Use the expression &lt;code&gt;{{$json["body"]["action"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click on the &lt;strong&gt;&lt;em&gt;Add Routing Rules&lt;/em&gt;&lt;/strong&gt; button. For this workflow, you need two different routes, one to handle each event mentioned above.&lt;/p&gt;

&lt;p&gt;The first routing rule handles the scenario where a new issue gets created. For this, configure the following parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation:&lt;/em&gt;&lt;/strong&gt; Equal&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 2:&lt;/em&gt;&lt;/strong&gt; opened&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Output:&lt;/em&gt;&lt;/strong&gt; 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second routing rule will navigate the data to output 1 of the Switch node when a new comment gets posted on an issue. For this, use the following values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation:&lt;/em&gt;&lt;/strong&gt; Equal&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 2:&lt;/em&gt;&lt;/strong&gt; created&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Output:&lt;/em&gt;&lt;/strong&gt; 1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--1N1Dte7n--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh4.googleusercontent.com/gJ-QoHkUjrtSUQjkFzHwrQZioJbUI3Q1PPomjsT7gceWT2sEM8zGRqHnXaP53IbhquqfCLpWk6lpseyFsjBllhA-ojgv1rwwCDNhkk4vcc_IRqPZ-9hogtAYGk-1NmHnkSQakV_U%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--1N1Dte7n--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh4.googleusercontent.com/gJ-QoHkUjrtSUQjkFzHwrQZioJbUI3Q1PPomjsT7gceWT2sEM8zGRqHnXaP53IbhquqfCLpWk6lpseyFsjBllhA-ojgv1rwwCDNhkk4vcc_IRqPZ-9hogtAYGk-1NmHnkSQakV_U%3Ds1600" alt="Configuration of Switch node"&gt;&lt;/a&gt;Configuration of Switch node&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Handle new GitHub issues
&lt;/h3&gt;

&lt;p&gt;Contributors who open a new issue may or may not want to contribute to it. To make sure that you assign the contributor only if they're interested in working on it, add an IF node connected to the output 0 of the Switch node.&lt;/p&gt;

&lt;p&gt;Configure the IF node as follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 1:&lt;/em&gt;&lt;/strong&gt; Add the expression &lt;code&gt;{{$json["body"]["issue"]["body"]}}&lt;/code&gt; in the Expression Editor.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation:&lt;/em&gt;&lt;/strong&gt; Regex&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Regex:&lt;/em&gt;&lt;/strong&gt; /[a,A]ssign[\w*\s*]*me/gm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The above condition checks if the user wants you to assign them the issue or not.&lt;/p&gt;

&lt;p&gt;The workflow should also check if the issue has already been assigned to a contributor or not. For this, add another condition in the IF node. This condition should be of the type Number. Set the following parameters for this condition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 1:&lt;/em&gt;&lt;/strong&gt; Use the expression &lt;code&gt;{{$json["body"]["issue"]["assignees"].length}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation:&lt;/em&gt;&lt;/strong&gt; Equal&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 2:&lt;/em&gt;&lt;/strong&gt; 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--V5liS2Gb--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/gMLzYulOUY_QY67YusMtV3Ug5lIwNwjuS_46PnmWLsVWbzR9hiczTUMbaq0xDyGAFVO9RaWJLxUNgRC5ZYdI01TNrKErRDm0a7Sr-nC-E-RPYopBuaultdx5-zSDga7XOcXxb2wD%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--V5liS2Gb--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/gMLzYulOUY_QY67YusMtV3Ug5lIwNwjuS_46PnmWLsVWbzR9hiczTUMbaq0xDyGAFVO9RaWJLxUNgRC5ZYdI01TNrKErRDm0a7Sr-nC-E-RPYopBuaultdx5-zSDga7XOcXxb2wD%3Ds1600" alt="Configuration of IF node for new GitHub issues"&gt;&lt;/a&gt;Configuration of IF node for new GitHub issues&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Assign the issue
&lt;/h3&gt;

&lt;p&gt;The next step is assigning the issue to the interested contributor and label the issue as &lt;code&gt;assigned&lt;/code&gt;. To do this, connect a GitHub node to the true output of the IF node and&lt;/p&gt;

&lt;p&gt;Configure the parameters of the node as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Authentication:&lt;/em&gt;&lt;/strong&gt; Use the authentication that you used in the GitHub Trigger node&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Resource:&lt;/em&gt;&lt;/strong&gt; Issue&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation&lt;/em&gt;&lt;/strong&gt;: Edit&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Repository Owner:&lt;/em&gt;&lt;/strong&gt; Enter your GitHub username or use the following expression: &lt;code&gt;{{$node["Switch"].json["body"]["repository"]["owner"]["login"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Repository Name:&lt;/em&gt;&lt;/strong&gt; Enter the name of the repository you previously used, or use the following expression: &lt;code&gt;{{$node["Switch"].json["body"]["repository"]["name"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Issue Number:&lt;/em&gt;&lt;/strong&gt; Use the expression &lt;code&gt;{{ $json["body"]["issue"]["number"] }}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click on &lt;strong&gt;&lt;em&gt;Choose Option To Add&lt;/em&gt;&lt;/strong&gt;, and select &lt;em&gt;Labels&lt;/em&gt;. Similarly, add the &lt;em&gt;Assignees&lt;/em&gt; option. Configure these parameters as follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;em&gt;Label:&lt;/em&gt; Enter &lt;code&gt;assigned&lt;/code&gt; or any other label you want to add to the issue.&lt;/li&gt;
&lt;li&gt;  &lt;em&gt;Assignees:&lt;/em&gt; Enter the following expression: &lt;code&gt;{{$json.body.issue["user"]["login"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--FhQQP_yj--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/bx_vDtHWhHYUeFfQnZLvMU9i2JLkphGcY7nY-KpXe_CutPFr8kfn5Jbki0XZf_T6z1EhXOlxC8ptTIhPdVB-CzOjTbQeFDHSaTnSsElEbhMzB4oTaquXRGuPBTfW76ElctlZ2_Hj%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--FhQQP_yj--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/bx_vDtHWhHYUeFfQnZLvMU9i2JLkphGcY7nY-KpXe_CutPFr8kfn5Jbki0XZf_T6z1EhXOlxC8ptTIhPdVB-CzOjTbQeFDHSaTnSsElEbhMzB4oTaquXRGuPBTfW76ElctlZ2_Hj%3Ds1600" alt="Configuration of GitHub node for issue assignment"&gt;&lt;/a&gt;Configuration of GitHub node for issue assignment&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Handle new comments on GitHub issues
&lt;/h3&gt;

&lt;p&gt;Your workflow currently handles the following situations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Assigning the issue to the contributor who opened the issue if the contributor is interested in working on it&lt;/li&gt;
&lt;li&gt; Stopping the workflow if the contributor who opened the issue is not interested in working on it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You now have to design the workflow that handles the other two scenarios. For this, add an IF node connected to output 1 of the Switch node. This node will check if the contributor is interested in working on the issue. Set the following parameters for the IF node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 1:&lt;/em&gt;&lt;/strong&gt; Use the expression &lt;code&gt;{{$json["body"]["comment"]["body"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation:&lt;/em&gt;&lt;/strong&gt; Regex&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Regex:&lt;/em&gt;&lt;/strong&gt; &lt;code&gt;/[a,A]ssign[\w*\s*]*me/gm&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--Sm76CC-1--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/UO42hKRGCuOaa2UJJzBHkvDdec-kkGlkC6BG0SUM4E5ApReED_UP_1yVRQ1WBHTfhIPmpg6kcWjKVcB6Yg_aWBfGIEPrtO_onM8HpOPqkHd2sAFZ6tbqrmv_m_JwzI7eQi-7cDSQ%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--Sm76CC-1--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/UO42hKRGCuOaa2UJJzBHkvDdec-kkGlkC6BG0SUM4E5ApReED_UP_1yVRQ1WBHTfhIPmpg6kcWjKVcB6Yg_aWBfGIEPrtO_onM8HpOPqkHd2sAFZ6tbqrmv_m_JwzI7eQi-7cDSQ%3Ds1600" alt="Configuration of IF node for interested contributors"&gt;&lt;/a&gt;Configuration of IF node for interested contributors&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Check if the issue is already assigned
&lt;/h3&gt;

&lt;p&gt;If a contributor is interested in working on an issue, the workflow should assign them to the issue, but it should also check if someone has previously been assigned to the issue or not. To check this, connect an IF node to the true output of the previous IF node. Set the IF node as follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 1:&lt;/em&gt;&lt;/strong&gt; Enter the expression &lt;code&gt;{{$json["body"]["issue"]["assignees"].length}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation:&lt;/em&gt;&lt;/strong&gt; Equal&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Value 2:&lt;/em&gt;&lt;/strong&gt; 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If no one is assigned to the issue, this node returns true, otherwise false.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--h31L89xC--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/XBawrwEP-EgWXjv4j2NDFUbfsmNVZmNYfvc0VuzODxxs_dBJ_G-vUrBUFcgXWXjn6fqFAkdTaNnY4jn5DebNYwHQ9kyrywH5on44IYIJppwiRm8-ZlApiFUTODXcI2VLaqBpzaP4%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--h31L89xC--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh6.googleusercontent.com/XBawrwEP-EgWXjv4j2NDFUbfsmNVZmNYfvc0VuzODxxs_dBJ_G-vUrBUFcgXWXjn6fqFAkdTaNnY4jn5DebNYwHQ9kyrywH5on44IYIJppwiRm8-ZlApiFUTODXcI2VLaqBpzaP4%3Ds1600" alt="Configuration of IF node for assigned issues"&gt;&lt;/a&gt;Configuration of IF node for assigned issues&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Assign an issue to a contributor
&lt;/h3&gt;

&lt;p&gt;To assign available issues to an interested contributor, connect a GitHub node to the true branch of the previous IF node.&lt;/p&gt;

&lt;p&gt;Configure the parameters similarly to the previous GitHub node. In the &lt;strong&gt;&lt;em&gt;Assignees&lt;/em&gt;&lt;/strong&gt; fields, use the expression &lt;code&gt;{{$json["body"]["comment"]["user"]["login"]}}&lt;/code&gt;. The rest of the parameters should stay the same.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Add a comment to the issue
&lt;/h3&gt;

&lt;p&gt;If the issue has already been assigned to a contributor, you should the interested contributor know about it. For this, connect a new GitHub node to the false branch of the IF node. This node will add a comment to the issue.&lt;/p&gt;

&lt;p&gt;Set the node parameters as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Authentication:&lt;/em&gt;&lt;/strong&gt; Similar to the previous GitHub node&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Resource:&lt;/em&gt;&lt;/strong&gt; Issue&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Operation:&lt;/em&gt;&lt;/strong&gt; Create Comment&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Repository Owner&lt;/em&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;em&gt;Repository Name&lt;/em&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;em&gt;Issue Number:&lt;/em&gt;&lt;/strong&gt; Similar to the previous GitHub node&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;&lt;em&gt;Body:&lt;/em&gt;&lt;/strong&gt; Enter the comment body in this field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--6_okXYh3--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh3.googleusercontent.com/Xw_WTysQ2JqGR6IepryBP9OM8oLBe7XDEzrPXGclgGYchpd38frV1xoKtxGEMHsZQVXGsPBHeDKjp_n_pF0U8FMDFJK9ddRH4zDio3qHeUObSxTk8cFri2WolUHxZXUXVxEdn82T%3Ds1600" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--6_okXYh3--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://lh3.googleusercontent.com/Xw_WTysQ2JqGR6IepryBP9OM8oLBe7XDEzrPXGclgGYchpd38frV1xoKtxGEMHsZQVXGsPBHeDKjp_n_pF0U8FMDFJK9ddRH4zDio3qHeUObSxTk8cFri2WolUHxZXUXVxEdn82T%3Ds1600" alt="Configuration of GitHub node for commenting on issues"&gt;&lt;/a&gt;Configuration of GitHub node for commenting on issues&lt;p&gt;&lt;/p&gt;

&lt;p&gt;This is the last node in the workflow. Now you only need to activate the workflow by clicking on the toggle in the top right corner of the Editor UI. The active workflow will run automatically in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;In this article, you learned to create a no-code workflow that automatically assigns GitHub issues to interested contributors. This kind of automation will help you and our organization maintain your open-source projects–just in time for Hacktoberfest!&lt;/p&gt;

&lt;p&gt;Here's what you can do next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Build more workflows:&lt;/strong&gt; There are many other possible automation workflows that could improve your work as a project maintainer and the experience for your contributors. For example, you can build a workflow that checks the progress on an assigned issue to  see if the contributor needs help.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Contribute to n8n:&lt;/strong&gt; You can work on &lt;a href="https://github.com/n8n-io/n8n/issues"&gt;open issues&lt;/a&gt;, &lt;a href="https://docs.n8n.io/nodes/creating-nodes/create-node.html#prerequisites"&gt;create nodes&lt;/a&gt;, &lt;a href="https://github.com/n8n-io/n8n-docs/blob/master/CONTRIBUTING.md"&gt;improve our docs&lt;/a&gt;, or &lt;a href="https://docs.n8n.io/reference/contributing.html#write-a-blogpost-%E2%9C%8D%EF%B8%8F"&gt;write a blog post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Read more tutorials:&lt;/strong&gt; Learn how to build workflows for other automation use cases, for example a no-code &lt;a href="https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/"&gt;CI/CD pipeline with GitHub and TravisCI&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd like to know how you are using automation to manage your open-source projects! Feel free to message me on &lt;a href="https://twitter.com/harshil1712"&gt;Twitter&lt;/a&gt; or discuss in the &lt;a href="https://community.n8n.io/c/docs-and-tutorials/6"&gt;community forum&lt;/a&gt; 🧡 And if you want to get the latest content on automation, &lt;a href="https://n8n.io/blog/#subscribe"&gt;subscribe to our newsletter&lt;/a&gt; 💌&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This &lt;a href="https://n8n.io/blog/automation-for-maintainers-of-open-source-projects/"&gt;post&lt;/a&gt; originally appeared on the n8n.io &lt;a href="https://n8n.io/blog"&gt;blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tutorial</category>
      <category>github</category>
      <category>hacktoberfest</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Creating triggers for n8n workflows using polling ⏲</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Thu, 14 Jan 2021 12:35:39 +0000</pubDate>
      <link>https://dev.to/n8n/creating-triggers-for-n8n-workflows-using-polling-17kf</link>
      <guid>https://dev.to/n8n/creating-triggers-for-n8n-workflows-using-polling-17kf</guid>
      <description>&lt;p&gt;One of the key features of n8n is the ability to use &lt;a href="https://docs.n8n.io/nodes/node-basics.html#trigger-nodes" rel="noopener noreferrer"&gt;Trigger nodes&lt;/a&gt;. Trigger nodes are special nodes that listen for events and then start a workflow. For example, you can build a workflow using the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.clickUpTrigger/#clickup-trigger" rel="noopener noreferrer"&gt;&lt;em&gt;ClickUp Trigger&lt;/em&gt;&lt;/a&gt; node and a &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.googleSheets/#google-sheets" rel="noopener noreferrer"&gt;&lt;em&gt;Google Sheets&lt;/em&gt;&lt;/a&gt; node. Whenever a new task gets created in ClickUp, the workflow would execute, and the data gets added to the Google Sheet. This makes Trigger nodes extremely useful, as you can monitor events across different services using them.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F1%2AG02tszmJsR5GNk5agiXtEw.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F1%2AG02tszmJsR5GNk5agiXtEw.jpeg"&gt;&lt;/a&gt;Creating triggers for n8n workflows using polling&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Once you start using several of these different Trigger nodes, you wonder what happens when you want to listen for an event from a service that does not have a Trigger node. For example, Google Sheets and Twitter (at the time of writing this tutorial — &lt;code&gt;n8n@0.101.0&lt;/code&gt;) don’t have a Trigger node.&lt;/p&gt;

&lt;p&gt;In this article, we are going to discuss how you can monitor changes and execute workflows for services that do not support &lt;a href="https://docs.n8n.io/reference/glossary.html#webhook" rel="noopener noreferrer"&gt;webhooks&lt;/a&gt; and don’t have Trigger nodes in n8n.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AAkQAL3ma93L9lZ4X" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AAkQAL3ma93L9lZ4X" alt="Monitoring changes in Google Sheets every 45 mins"&gt;&lt;/a&gt;Monitoring changes in Google Sheets every 45 mins&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Introduction to polling
&lt;/h3&gt;

&lt;p&gt;In a conventional n8n Trigger node, the node listens for events using a webhook. In this method, the service sends an update to the Trigger node when an event occurs. While polling works by periodically checking for new data by connecting to the server.&lt;/p&gt;

&lt;p&gt;In this process, n8n will connect to the server every X time (every second/minute, etc) and check if there is any new data.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AuYi8hOzlzeRg3dpw" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AuYi8hOzlzeRg3dpw" alt="Difference between polling and webhook"&gt;&lt;/a&gt;Difference between polling and webhook&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Not every platform supports sending updates via a webhook and this is where polling comes to the rescue. Using polling we can check for updates and execute our workflow if data gets added or updated.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to poll using n8n?
&lt;/h3&gt;

&lt;p&gt;If you break down the polling process, as described in the image above, you realize that it is possible to replicate the same functionality in n8n. The &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.interval/#interval" rel="noopener noreferrer"&gt;&lt;em&gt;Interval&lt;/em&gt;&lt;/a&gt; node or the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.cron/#cron" rel="noopener noreferrer"&gt;&lt;em&gt;Cron&lt;/em&gt;&lt;/a&gt; node triggers the workflow periodically to fetch data from a service. We pass this data to the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.function/" rel="noopener noreferrer"&gt;&lt;em&gt;Function&lt;/em&gt;&lt;/a&gt; node, where all the magic happens.&lt;/p&gt;

&lt;p&gt;In n8n, the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.function/#method-getworkflowstaticdata-type" rel="noopener noreferrer"&gt;getWorkflowStaticData()&lt;/a&gt; method allows access to the static workflow data. We can save the data directly with the workflow, but this data has to be small. On every execution, the Function node compares the incoming data with the data from the previous execution. If the data got changed, we pass it to the next node in the workflow. We also update the static data with this new data so that the next execution knows what data gets stored in the previous node. If the data did not get changed, you may return a message based on our use-case.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ALyV9p3fJq5f5aHEP" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ALyV9p3fJq5f5aHEP" alt="Polling in n8n"&gt;&lt;/a&gt;Polling in n8n&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Trigger a workflow when new data gets added to your Google Sheets
&lt;/h3&gt;

&lt;p&gt;I wanted to run a workflow every time there was new data added in Google Sheets, so I created a polling workflow. Follow along the steps mentioned below to learn to create a polling workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Set up n8n
&lt;/h4&gt;

&lt;p&gt;Follow the instructions mentioned in the &lt;a href="https://docs.n8n.io/getting-started/quickstart.html#quickstart" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; to install and spin-up an n8n instance on your machine. You can also sign-up for &lt;a href="https://n8n.io/cloud" rel="noopener noreferrer"&gt;n8n.cloud&lt;/a&gt; to get access to our hosted service.&lt;/p&gt;

&lt;h4&gt;
  
  
  Set up a Google Sheet
&lt;/h4&gt;

&lt;p&gt;Create a Google Sheet like &lt;a href="https://docs.google.com/spreadsheets/d/1PyC-U1lXSCbxVmHuwFbkKDF9e3PW_iUn8T-iAd_MYjQ/edit?usp=sharing" rel="noopener noreferrer"&gt;this Google Sheet&lt;/a&gt;. We will monitor this sheet for new data that gets added. Feel free to make a copy of my Sheet.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AZ0KpvBzTdDEYWb5u" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AZ0KpvBzTdDEYWb5u" alt="Google Sheet used in this tutorial"&gt;&lt;/a&gt;Google Sheet used in this tutorial&lt;p&gt;&lt;/p&gt;

&lt;p&gt;We will also need to configure credentials for the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.googleSheets/#google-sheets" rel="noopener noreferrer"&gt;&lt;em&gt;Google Sheets&lt;/em&gt;&lt;/a&gt; node. For this tutorial, we will use the OAuth authentication method. You can learn to configure the OAuth credentials by following the steps mentioned in the &lt;a href="https://docs.n8n.io/credentials/google/#using-oauth" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Set up Mattermost
&lt;/h4&gt;

&lt;p&gt;We will send a message to a channel in a Mattermost team if new data gets added. Create a Mattermost team if you don’t have one already.&lt;/p&gt;

&lt;p&gt;You will also have to configure the credentials for the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.mattermost" rel="noopener noreferrer"&gt;&lt;em&gt;Mattermost&lt;/em&gt;&lt;/a&gt; node. Follow the instructions mentioned in the &lt;a href="https://docs.n8n.io/credentials/mattermost/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; to configure the credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick Start
&lt;/h3&gt;

&lt;p&gt;If you don’t want to get into the details and have experience building workflows in n8n, you can follow the quick-start instructions below. If something is unclear or you want to learn more about how it works, feel free to dig deeper into the sections that follow.&lt;/p&gt;

&lt;p&gt;Access your n8n instance, and copy and paste the workflow from the &lt;a href="https://n8n.io/workflows/864" rel="noopener noreferrer"&gt;workflow&lt;/a&gt; page. Configure the following nodes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Google Sheets node&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Use the credentials that you configured earlier.&lt;/li&gt;
&lt;li&gt;Enter the Sheet ID of your Sheet in the &lt;strong&gt;&lt;em&gt;Sheet ID&lt;/em&gt;&lt;/strong&gt; field. If you’re not sure how to find the Sheet ID, refer to the section Google Sheets section below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt; : If you change a column name in your Google Sheet, make sure to configure the &lt;em&gt;Function&lt;/em&gt; node accordingly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Mattermost node&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Use the credentials that you configured earlier.&lt;/li&gt;
&lt;li&gt;If you’re not the system administrator, enter the channel ID where you want the app to send the message in the &lt;strong&gt;&lt;em&gt;Channel ID&lt;/em&gt;&lt;/strong&gt; field. Otherwise, select the channel from the &lt;strong&gt;&lt;em&gt;Channel ID&lt;/em&gt;&lt;/strong&gt; dropdown list. Refer to the Mattermost section below, to learn more about the steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Save and execute the workflow!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt; : Activate the workflow to run it in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Workflow
&lt;/h3&gt;

&lt;p&gt;This workflow can be divided into three stages, as it progresses from start to finish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read Data&lt;/li&gt;
&lt;li&gt;Extract New Data&lt;/li&gt;
&lt;li&gt;Communication&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Stage 1 — Read Data
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Interval node (Execute every 45 mins)
&lt;/h4&gt;

&lt;p&gt;The &lt;em&gt;Interval&lt;/em&gt; node triggers the workflow at a regular interval of time. For this tutorial, we want to execute the workflow every 45 minutes.&lt;/p&gt;

&lt;p&gt;Click on the ‘ &lt;strong&gt;&lt;em&gt;+&lt;/em&gt;&lt;/strong&gt; ’ button on the top right corner and click on the &lt;strong&gt;&lt;em&gt;Trigger&lt;/em&gt;&lt;/strong&gt; tab. Select the &lt;em&gt;Interval&lt;/em&gt; node from the list to add the node.&lt;/p&gt;

&lt;p&gt;Select ‘Minutes’ from the &lt;strong&gt;&lt;em&gt;Unit&lt;/em&gt;&lt;/strong&gt; dropdown list and set the value of &lt;strong&gt;&lt;em&gt;Interval&lt;/em&gt;&lt;/strong&gt; to 45.&lt;/p&gt;

&lt;p&gt;Rename the node’s headline from “Interval” to “Execute every 45 mins” by clicking the name, editing it, and clicking the ✔ (checkmark) to the right of the name. This will finish the configuration of the node and close the parameters window. Select ‘Save As’ from the &lt;strong&gt;&lt;em&gt;Workflow&lt;/em&gt;&lt;/strong&gt; menu on the left sidebar. Once you save the workflow, click on the &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; button to execute the node.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/QRz57ZNmOps"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h4&gt;
  
  
  Google Sheets node (Read data)
&lt;/h4&gt;

&lt;p&gt;The &lt;em&gt;Google Sheets&lt;/em&gt; node in n8n allows you to create new sheets and read, lookup, append, delete, and update data from a sheet. Our workflow will read the data from a Google Sheet every 45 minutes.&lt;/p&gt;

&lt;p&gt;To add the &lt;em&gt;Google Sheets&lt;/em&gt; node, click on the ‘ &lt;strong&gt;&lt;em&gt;+&lt;/em&gt;&lt;/strong&gt; ’ button, and select the &lt;em&gt;Google Sheets&lt;/em&gt; node from the &lt;strong&gt;&lt;em&gt;Regular&lt;/em&gt;&lt;/strong&gt; section. Use the credentials that you configured earlier.&lt;/p&gt;

&lt;p&gt;Copy the string of characters located between /d/ and /edit in your spreadsheet URL. This string is the Sheet ID. For example, for the URL &lt;a href="https://docs.google.com/spreadsheets/d/1PyCgaglXSCbxVmHuwFbkKDF9e3PW%5C_iUn8T-iAd%5C_MYjQ/edit#gid=0" rel="noopener noreferrer"&gt;https://docs.google.com/spreadsheets/d/1PyCgaglXSCbxVmHuwFbkKDF9e3PW\_iUn8T-iAd\_MYjQ/edit#gid=0&lt;/a&gt; the Sheet ID is 1PyCgaglXSCbxVmHuwFbkKDF9e3PW_iUn8T-iAd_MYjQ. Enter this Sheet ID in the &lt;strong&gt;&lt;em&gt;Sheet ID&lt;/em&gt;&lt;/strong&gt; field in the &lt;em&gt;Google Sheet&lt;/em&gt; node. Enter the range of the columns in the &lt;strong&gt;&lt;em&gt;Range&lt;/em&gt;&lt;/strong&gt; field. The range contains the column references corresponding to those on the spreadsheet. The range tells the &lt;em&gt;Google Sheets&lt;/em&gt; node from which columns to read the data.&lt;/p&gt;

&lt;p&gt;Rename the node to &lt;strong&gt;&lt;em&gt;Read data&lt;/em&gt;&lt;/strong&gt; and click on the &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; button. The node will return the data from the Google Sheet.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/AbgqDJzaybk"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2- Extract new data
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Function node (Check if new data)
&lt;/h4&gt;

&lt;p&gt;Our workflow reads the data from a Google Sheet and returns all the data. However, we only want the data that was recently added. We will use the &lt;em&gt;Function&lt;/em&gt; node to return only the data that was added after the previous execution.&lt;/p&gt;

&lt;p&gt;To help you understand this better, let us take a look at an example. Our workflow ran at 1:00 PM and returned the following data:&lt;/p&gt;

&lt;p&gt;New data was added to our Google Sheet at 1:20 PM. When your workflow will execute next, we only want this new data. Using the &lt;em&gt;Function&lt;/em&gt; node we will return this new data. n8n has a getWorkflowStaticData() method that gives access to the static workflow data. We can save data directly with the workflow, however, this data should be very small. When the workflow execution succeeds, n8n will check automatically if the data has changed and will save it, if necessary.&lt;/p&gt;

&lt;p&gt;Click on the ‘ &lt;strong&gt;&lt;em&gt;+&lt;/em&gt;&lt;/strong&gt; ’ icon and select the &lt;em&gt;Function&lt;/em&gt; node from the list. In the &lt;strong&gt;&lt;em&gt;Javascript Code&lt;/em&gt;&lt;/strong&gt; editor, paste the following code snippet.&lt;/p&gt;

&lt;p&gt;The above code snippet gets the static workflow data and checks for new items. If new items are found, it is added to an array and gets returned by the node. If the data remains unchanged, an empty array is returned by the node.&lt;/p&gt;

&lt;p&gt;Rename the node to &lt;strong&gt;&lt;em&gt;Check if new data&lt;/em&gt;&lt;/strong&gt; and click on the &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt;  button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt; : The static data can not be read and written when we’re building the workflow. Hence, on every execution, the node will return all the data. When the workflow is set to active and triggered by a Trigger node, the node will save the static data and return only the new data that was added (if any).&lt;/p&gt;

&lt;p&gt;The following image shows the output returned by the node when the workflow is not active.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ABn8QcRdpxQxypJ3E" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ABn8QcRdpxQxypJ3E" alt="Output returned by the Function node when the workflow is not active"&gt;&lt;/a&gt;Output returned by the Function node when the workflow is not active&lt;p&gt;&lt;/p&gt;

&lt;p&gt;The following image shows the output returned by the node when the workflow is active.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A2KJzyq5WQaBBxOk-" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A2KJzyq5WQaBBxOk-" alt="Output returned by the Function node when the workflow is active"&gt;&lt;/a&gt;Output returned by the Function node when the workflow is active&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/TUyzzWRNYr4"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3- Communication
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Mattermost node (Send message)
&lt;/h4&gt;

&lt;p&gt;If new data was added to the Google Sheet we will send a message to Mattermost along with the new data. We will send a message to the updates channel. But you can select a different channel to send the message.&lt;/p&gt;

&lt;p&gt;Click on the ‘ &lt;strong&gt;&lt;em&gt;+&lt;/em&gt;&lt;/strong&gt; ’ button and select the &lt;em&gt;Mattermost&lt;/em&gt; node. Connect the node to the &lt;strong&gt;&lt;em&gt;true&lt;/em&gt;&lt;/strong&gt; branch of the &lt;em&gt;Is new item?&lt;/em&gt; node. Use the credentials you configured earlier.&lt;/p&gt;

&lt;p&gt;If you are not the system administrator of the Mattermost team, navigate to Mattermost and select the ‘updates’ channel from the left sidebar. Click on the caret next to ‘updates’ and select ‘View Info’ from the dropdown list. Copy and paste the displayed ID in the &lt;strong&gt;&lt;em&gt;Channel ID&lt;/em&gt;&lt;/strong&gt; field in the Mattermost node.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ARLfOtAjF0hvzz9rk" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ARLfOtAjF0hvzz9rk" alt="Finding channel ID of a Mattermost channel"&gt;&lt;/a&gt;Finding channel ID of a Mattermost channel&lt;p&gt;&lt;/p&gt;

&lt;p&gt;If you’re the system administrator, select the ‘updates’ channel from the &lt;strong&gt;&lt;em&gt;Channel ID&lt;/em&gt;&lt;/strong&gt; dropdown list.&lt;/p&gt;

&lt;p&gt;Click on the gears icon next to the &lt;strong&gt;&lt;em&gt;Message&lt;/em&gt;&lt;/strong&gt; field and select ‘Add Expression’. Enter the following expression in the &lt;strong&gt;&lt;em&gt;Expression Editor&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Rename the node to &lt;strong&gt;&lt;em&gt;Send message&lt;/em&gt;&lt;/strong&gt; and click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/txflaaQbjZE"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s next?
&lt;/h3&gt;

&lt;p&gt;Currently, the &lt;em&gt;Check if new data&lt;/em&gt; node in our workflow returns all the items since the static data is not getting saved. Save the workflow and toggle the Active, on the top right, to true. This will set our workflow to active and our workflow will execute every 45 mins. Add new data to the Google Sheets, and after 45 mins (for testing you can set the time to 2 mins!) you will receive a message on Mattermost.&lt;/p&gt;

&lt;p&gt;Such polling workflows can be used for various use-cases. If you’re using Google Forms to collect data, and if the form adds the data to a Google Sheet, you can build a workflow that will trigger the workflow whenever there is a new form response.&lt;/p&gt;

&lt;p&gt;Another interesting use-case can be for Twitter. You can build a polling workflow that executes every 10 mins. This workflow would fetch Tweets for your search criteria using the Twitter node and return the most recent tweets. At n8n, we use a similar workflow that sends a message to Mattermost with the recent Tweets. You can find the workflow on the &lt;a href="https://n8n.io/workflows/875" rel="noopener noreferrer"&gt;workflow page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A few more examples where polling can help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trigger a workflow when an event gets added to your Google Calendar using the Google Calendar node&lt;/li&gt;
&lt;li&gt;Trigger a workflow when price changes for a cryptocurrency using the CoinGecko node&lt;/li&gt;
&lt;li&gt;Trigger a workflow when new data gets added to your database (MongoDB, Postgres, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Summary
&lt;/h3&gt;

&lt;p&gt;In this tutorial, we learned how polling can help us overcome a barrier for the nodes that do not have a Trigger node. We can use polling with the nodes that do not have a default Trigger node and build a workflow based to trigger actions when new data is added or updated.&lt;/p&gt;

&lt;p&gt;We built a polling workflow that executes every 45 mins and reads data from a Google Sheet. If the data has been updated or new data has been added, the workflow sends a message to Mattermost.&lt;/p&gt;

&lt;p&gt;I’d like to know about how you are using the concept of polling and building workflows with n8n! If you run into any issues while following the tutorial, feel free to message me on &lt;a href="https://twitter.com/harshil1712" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt; or ask for help on our&lt;a href="https://community.n8n.io" rel="noopener noreferrer"&gt;forum&lt;/a&gt; 🧡&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This &lt;a href="https://n8n.io/blog/creating-triggers-for-n8n-workflows-using-polling/" rel="noopener noreferrer"&gt;post&lt;/a&gt; originally appeared on the n8n.io &lt;a href="https://n8n.io/blog/" rel="noopener noreferrer"&gt;blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




</description>
      <category>googlesheets</category>
      <category>mattermost</category>
      <category>automation</category>
      <category>polling</category>
    </item>
    <item>
      <title>Migrating Community Metrics to Orbit using n8n 📈</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Fri, 18 Dec 2020 16:04:36 +0000</pubDate>
      <link>https://dev.to/n8n/migrating-community-metrics-to-orbit-using-n8n-5d5i</link>
      <guid>https://dev.to/n8n/migrating-community-metrics-to-orbit-using-n8n-5d5i</guid>
      <description>&lt;p&gt;It has been more than two years since I have been involved in the community as an organizer. My journey started as a &lt;a href="https://education.github.com/experts"&gt;GitHub Campus Expert&lt;/a&gt;. My love for privacy and the open internet helped me become a Mozilla Representative, and I am now an &lt;a href="https://auth0.com/ambassador-program/"&gt;Auth0 Ambassador&lt;/a&gt; too. This journey is teaching me new skills and pushes me to do better. This is the aim I have for the community as well. Every member should get the opportunity to grow, showcase their skills, and achieve their goals.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--DN1keHUX--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/1%2AX0lQK1vlnr7CXKUDKRdSnw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--DN1keHUX--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/1%2AX0lQK1vlnr7CXKUDKRdSnw.png" alt=""&gt;&lt;/a&gt;Migrating Community Metrics to Orbit using n8n&lt;p&gt;&lt;/p&gt;

&lt;p&gt;To know what everyone was up to, I started keeping records of the activities of the community members using Google Sheets. But with an ever-growing community, it became difficult. Every time a community member contributed, I manually added these records to a Google Sheet. The members of the community are very active, and it became difficult to keep up with the pace. Structuring data in Google Sheets for community metrics was also a painful task. The biggest challenge was making sense of these metrics. So I started using Orbit and connected my Google Sheets to it using &lt;a href="https://n8n.io"&gt;n8n&lt;/a&gt; to transfer data automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is Orbit?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/orbit-love/orbit-model"&gt;Orbit model&lt;/a&gt; is a framework for building communities. &lt;a href="https://orbit.love/"&gt;Orbit&lt;/a&gt; makes it easy for community leaders and Developer Relations (DevRel) Leadership to easily keep track of the DevRel metrics and quantify the business impact.&lt;/p&gt;

&lt;p&gt;I started at &lt;a href="https://n8n.io/"&gt;n8n&lt;/a&gt; as a Developer Advocate Intern and was excited to learn that we use Orbit for DevRel metrics. I had read about Orbit and was eager to know more about it. I explored Orbit for a while and ended up creating an n8n workflow that moved the metrics from Google Sheets to Orbit.&lt;/p&gt;

&lt;p&gt;Like me, if you too manage DevRel metrics in Google Sheets (or Airtable), follow along the article to learn how you can move these metrics to Orbit using n8n.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/fNRYd3aStDI"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Prerequisites&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Orbit account&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;You can request access to Orbit by filling a form on their &lt;a href="https://orbit.love/"&gt;website&lt;/a&gt;. Once you have access to Orbit, create a workspace for your community.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Google account&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;I used Google Sheets to keep a record of the metrics in this &lt;a href="https://docs.google.com/spreadsheets/d/1GiR5glinWBUJ-pw3w8LpcuwyOXst2z5nnFSak8DQrMQ/edit?usp=sharing"&gt;spreadsheet&lt;/a&gt;. You will need a Google account to create a Google Sheet. Sign in to your Google account and open this &lt;a href="https://docs.google.com/spreadsheets/d/1GiR5glinWBUJ-pw3w8LpcuwyOXst2z5nnFSak8DQrMQ/edit?usp=sharing"&gt;sheet&lt;/a&gt;. Click on the &lt;strong&gt;&lt;em&gt;File&lt;/em&gt;&lt;/strong&gt; and select ‘Make a Copy’ from the dropdown list to copy the sheet in your account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you change the name of the columns or the name of the sheet, make sure to make the changes in the n8n workflow accordingly.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;n8n&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;We will connect Google Sheets to Orbit using n8n. Follow the instructions mentioned in the &lt;a href="https://docs.n8n.io/getting-started/quickstart.html#quickstart"&gt;documentation&lt;/a&gt; to install and spin-up an n8n instance on your machine. You can also sign-up for &lt;a href="https://n8n.cloud/"&gt;n8n.cloud&lt;/a&gt; to get access to our hosted service (in beta).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Quickstart&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If you don’t want to get into the details and have a bit of experience with n8n, you can follow the quick-start instructions below to get to the endgame. If something is unclear or you want to learn more about how it works, feel free to dig deeper into the sections that follow.&lt;/p&gt;

&lt;p&gt;Access your n8n instance, and copy and paste the workflow from the &lt;a href="https://n8n.io/workflows/829"&gt;workflow&lt;/a&gt; page. Configure the credentials for the following nodes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Sheets node&lt;/strong&gt; : &lt;a href="https://docs.n8n.io/credentials/google/#using-oauth"&gt;configure the OAuth credentials&lt;/a&gt; and make sure you enter the correct Sheet ID in the &lt;strong&gt;&lt;em&gt;Sheet ID&lt;/em&gt;&lt;/strong&gt;  field.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Get Members&lt;/strong&gt; : This Google Sheet node fetches the data from the Members sheet. If your sheet has a different name, modify the &lt;strong&gt;&lt;em&gt;Range&lt;/em&gt;&lt;/strong&gt; field accordingly. If you’re using the Google Sheet shared earlier, enter the following range: Members!A:F.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get Activities&lt;/strong&gt; : We fetch the activities of our members from the Activities sheet. If you are using a different name for your sheet, modify the &lt;strong&gt;&lt;em&gt;Range&lt;/em&gt;&lt;/strong&gt; field accordingly. For the spreadsheet I shared earlier, enter the following range: Activities!A:D.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Orbit node&lt;/strong&gt; : &lt;a href="https://docs.n8n.io/credentials/orbit/#orbit"&gt;configure the credentials&lt;/a&gt; and select your workspace from the &lt;strong&gt;&lt;em&gt;Workspace&lt;/em&gt;&lt;/strong&gt; field.&lt;/p&gt;

&lt;p&gt;Save and execute the workflow.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--WBxApxLg--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2A7OgrdvP_nbB0_OdX" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--WBxApxLg--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2A7OgrdvP_nbB0_OdX" alt=""&gt;&lt;/a&gt;The final workflow in n8n&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Moving data from Google Sheets to Orbit automatically&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Our workflow will fetch the data of our community members and their activities from a Google Sheet and add the information in our Orbit workspace automatically. The workflow uses the following nodes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get Members (&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.googleSheets/#google-sheets"&gt;Google Sheets node&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Add Members (&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.orbit/#orbit"&gt;Orbit node&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Get Activities (Google Sheets node)&lt;/li&gt;
&lt;li&gt;Get all members (Orbit node)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.merge/#merge"&gt;Merge&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Add Activities (Orbit node)&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;1. Get Members (Google Sheets node)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;We are storing our members’ information like their name, GitHub username, Twitter handle, etc. in a Google Sheet. We will use the Google Sheet node to fetch this information.&lt;/p&gt;

&lt;p&gt;The following image shows the Members sheet in my Google Sheet.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--sA-uAZ7X--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ARgbtItWePZ6O13mS" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--sA-uAZ7X--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ARgbtItWePZ6O13mS" alt="Storing members’ information in Google Sheet"&gt;&lt;/a&gt;Storing members’ information in Google Sheet&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Follow the instructions mentioned in the &lt;a href="https://docs.n8n.io/credentials/google/#prerequisites"&gt;documentation&lt;/a&gt; to configure the credentials for the Google Sheets node. Enter your Sheet ID in the &lt;strong&gt;&lt;em&gt;Sheet ID&lt;/em&gt;&lt;/strong&gt; field and enter a range in the &lt;strong&gt;&lt;em&gt;Range&lt;/em&gt;&lt;/strong&gt; field. If you’re using the Google Sheet shared earlier, enter the following range: Members!A:F.&lt;/p&gt;

&lt;p&gt;Rename the node to &lt;strong&gt;Get Members&lt;/strong&gt; and click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node.&lt;/p&gt;

&lt;p&gt;The following image shows the output of the Get Members node.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--0epWl4kq--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AJvabJNqLiL33A7Rv" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--0epWl4kq--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AJvabJNqLiL33A7Rv" alt="Fetching members’ data using Google Sheets node"&gt;&lt;/a&gt;Fetching members’ data using Google Sheets node&lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;2. Add Members (Orbit node)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;We will use the members’ data that we received from the previous node and pass it on to the Orbit node. To configure the credentials of the Orbit node, follow the instructions mentioned in the &lt;a href="https://docs.n8n.io/credentials/orbit/#orbit"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Select the ‘Upsert’ operation and your workspace in the Orbit node. We are using the GitHub identity to add the members. Select ‘GitHub’ from the &lt;strong&gt;&lt;em&gt;Source&lt;/em&gt;&lt;/strong&gt; dropdown list and ‘Username’ from the &lt;strong&gt;&lt;em&gt;Search By&lt;/em&gt;&lt;/strong&gt; dropdown list. In the username parameter of the node, use the following expression: &lt;code&gt;{{$json["GitHub"]}}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In our Google Sheet, we also have information like Location, Tags, Name, and T-shirt size. We will add this information to Orbit as well. You can add this information by selecting them from the &lt;strong&gt;&lt;em&gt;Add Field&lt;/em&gt;&lt;/strong&gt; dropdown list. Enter the following expression in the Expression Editor of the respective fields.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Location&lt;/strong&gt; : &lt;code&gt;{{$json["Location"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt; : &lt;code&gt;{{$json["Name"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags to Add&lt;/strong&gt; : &lt;code&gt;{{$json["Tags']}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;T-Shirt&lt;/strong&gt; : &lt;code&gt;{{$json["T-Shirt Size"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rename the node to &lt;strong&gt;Add Members&lt;/strong&gt; and click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node.&lt;/p&gt;

&lt;p&gt;The following image shows the output of the Add Members node.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--jhUsrcPR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AXkGkTUKEbAY_LyaB" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--jhUsrcPR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AXkGkTUKEbAY_LyaB" alt="Adding members to Orbit"&gt;&lt;/a&gt;Adding members to Orbit&lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;3. Get Activities (Google Sheets node)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Keeping only the data of the community members is not that useful. We also need to keep records of their activities. This record helps us understand the contributions the community members make, the type of content they are involved with, and other critical insights.&lt;/p&gt;

&lt;p&gt;The following image shows the Activities sheet in my Google Sheet.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--HuxfLJFJ--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AHqrwSNK7YMGnBfqY" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--HuxfLJFJ--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AHqrwSNK7YMGnBfqY" alt="Storing members’ activities in Google Sheet"&gt;&lt;/a&gt;Storing members’ activities in Google Sheet&lt;p&gt;&lt;/p&gt;

&lt;p&gt;We will get the records of the activities of our community members from the Google sheet and add them to Orbit.&lt;/p&gt;

&lt;p&gt;Select the credentials that we used in the Get Members node. Our Google Sheet uses the members’ GitHub username to associate them with their activities. We will use the ‘Lookup’ operation to get this information. Enter the following in the expression editor for the &lt;strong&gt;&lt;em&gt;Sheet ID&lt;/em&gt;&lt;/strong&gt; parameter: &lt;code&gt;{{$node["Get Members"].parameter["sheetId"]}}&lt;/code&gt;. Enter the range for our Google Sheet in the &lt;strong&gt;&lt;em&gt;Range&lt;/em&gt;&lt;/strong&gt; field. If you’re using the Google Sheet shared earlier, enter the following range: Activities!A:D.&lt;/p&gt;

&lt;p&gt;Enter ‘GitHub Username’ in the &lt;strong&gt;&lt;em&gt;Lookup Column&lt;/em&gt;&lt;/strong&gt; field. If you are using a different column name, enter that instead.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;&lt;em&gt;Lookup Value&lt;/em&gt;&lt;/strong&gt; field, we pass the GitHub username of all our members that we got from the Get Members node. Enter the following expression in the Lookup Value field: &lt;code&gt;{{$node["Get Members"].json["GitHub"]}}&lt;/code&gt;. Toggle &lt;strong&gt;&lt;em&gt;Return All Match&lt;/em&gt;&lt;/strong&gt; to true. This will check for the activities of all the members and return all their activities if it exists.&lt;/p&gt;

&lt;p&gt;Rename the node to &lt;strong&gt;Get Activities&lt;/strong&gt; and click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node.&lt;/p&gt;

&lt;p&gt;The following image shows the output from the Get Activities node.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--WIDY5qol--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2Az7GcVUihyn7KscFq" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--WIDY5qol--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2Az7GcVUihyn7KscFq" alt="Getting members’ activities using the Google Sheet node"&gt;&lt;/a&gt;Getting members’ activities using the Google Sheet node&lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;4. Get all members (Orbit node)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;To add the activities that we got from the previous node, we need the ID of the members that were generated by Orbit. This node will get the ID of the members along with other information.&lt;/p&gt;

&lt;p&gt;Create a new Orbit node and select the credentials that we used in the Add Members node. Select ‘Get All’ from the &lt;strong&gt;&lt;em&gt;Operation&lt;/em&gt;&lt;/strong&gt; dropdown list. Select your workspace and toggle &lt;strong&gt;&lt;em&gt;Return All&lt;/em&gt;&lt;/strong&gt; to true.&lt;/p&gt;

&lt;p&gt;Rename the node to Get all members and click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;NOTE:&lt;/em&gt;&lt;/strong&gt; Depending on the amount of data we have, the Orbit node might take some time to add the new members to the workspace.&lt;/p&gt;

&lt;p&gt;The following image shows the output from the Get all members node.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--ObHbWANY--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ALa22TOQg--fO4Xs2" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--ObHbWANY--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ALa22TOQg--fO4Xs2" alt="Getting all members using the Orbit node"&gt;&lt;/a&gt;Getting all members using the Orbit node&lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;5. Merge information Google Sheets and Orbit&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;We now have all the activities of our members from Google Sheet and their member ID from Orbit. We need to merge this information to map the activities to their respective member IDs.&lt;/p&gt;

&lt;p&gt;We are using the Merge node to merge this information. Connect one input of the node with the &lt;strong&gt;&lt;em&gt;Get Activities&lt;/em&gt;&lt;/strong&gt; node and the other input with the &lt;strong&gt;&lt;em&gt;Get all members&lt;/em&gt;&lt;/strong&gt;  node.&lt;/p&gt;

&lt;p&gt;Select ‘Merge By Key’ from the &lt;strong&gt;&lt;em&gt;Mode&lt;/em&gt;&lt;/strong&gt; dropdown list. This option will merge the information using the GitHub username as the key. Enter ‘GitHub Username’ in the &lt;strong&gt;&lt;em&gt;Property Input 1&lt;/em&gt;&lt;/strong&gt; field and ‘attributes.slug’ in the &lt;strong&gt;&lt;em&gt;Property Input 2&lt;/em&gt;&lt;/strong&gt;  field.&lt;/p&gt;

&lt;p&gt;Click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node.&lt;/p&gt;

&lt;p&gt;The following image shows the output from the Merge node.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--Vs1OZbc---/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AdnhrI6tes8oK9FDn" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--Vs1OZbc---/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AdnhrI6tes8oK9FDn" alt="Merging data from Google Sheets and Orbit"&gt;&lt;/a&gt;Merging data from Google Sheets and Orbit&lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;6. Add Activities (Orbit node)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The Merge node returns all the necessary data that we need. Using the Add Activities node, we will add the activities of our community members to Orbit.&lt;/p&gt;

&lt;p&gt;In our new Orbit node, select the credentials that we used for the Get all members node. Select ‘Activity’ from the &lt;strong&gt;&lt;em&gt;Resource&lt;/em&gt;&lt;/strong&gt; dropdown list and ‘Create’ from the &lt;strong&gt;&lt;em&gt;Operation&lt;/em&gt;&lt;/strong&gt; dropdown list.&lt;/p&gt;

&lt;p&gt;In the expression editor for the &lt;strong&gt;&lt;em&gt;Member ID&lt;/em&gt;&lt;/strong&gt; field, enter the following expression: &lt;code&gt;{{$json["id"]}}&lt;/code&gt;. Enter the following expression in the expression editor of the &lt;strong&gt;&lt;em&gt;Title&lt;/em&gt;&lt;/strong&gt; field: &lt;code&gt;{{$json["Title"]}}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We will also add a description and a link (if they exist) for the activities. Add the &lt;strong&gt;&lt;em&gt;Description&lt;/em&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;em&gt;Link&lt;/em&gt;&lt;/strong&gt; fields from the &lt;strong&gt;&lt;em&gt;Add Field&lt;/em&gt;&lt;/strong&gt; dropdown list. Enter the following expression in the Expression Editor of the respective fields.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Description&lt;/strong&gt; : &lt;code&gt;{{$json["Description"}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link&lt;/strong&gt; : &lt;code&gt;{{$json["Activity Link"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rename the node to &lt;strong&gt;Add Activities&lt;/strong&gt; and click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node.&lt;/p&gt;

&lt;p&gt;The following image shows the output from the Add Activities node.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--nmhyiYZt--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AUEmR8kTRarWgvbcR" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--nmhyiYZt--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AUEmR8kTRarWgvbcR" alt="Adding activities using the Orbit node"&gt;&lt;/a&gt;Adding activities using the Orbit node&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What’s next?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The current workflow shows how we can migrate our data from Google Sheet to Orbit. But we might be using Airtable or a CRM tool to manage these records. We can migrate these records to Orbit using n8n in less than 10 mins.&lt;/p&gt;

&lt;p&gt;We can build another workflow using the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.cron/#cron"&gt;Cron&lt;/a&gt; node and the Orbit node to get the information about members with the highest activity in a month. Appreciate their contributions in the community using the Mattermost node or the Slack node (or whatever your community uses), and tweet about their work using the Twitter node.&lt;/p&gt;

&lt;p&gt;I previously wrote an &lt;a href="https://medium.com/n8n-io/giving-kudos-to-contributors-with-github-slack-and-n8n-b3f5f4a653a6"&gt;article&lt;/a&gt; on how you can use the Slash command in Slack and integrate it with n8n to get community members’ information and give a shout-out to them. We can combine these workflows. Use the Orbit node to get the data (email, address, t-shirt size) of the members, the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.shopify/#shopify"&gt;Shopify&lt;/a&gt; node to place an order for swags. If you use &lt;a href="https://www.printfection.com/"&gt;Printfection&lt;/a&gt;, you can use the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.httpRequest/#http-request"&gt;HTTP Request&lt;/a&gt; node to place the order on Printfection. Let us recognize the contributions of the community!&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Summary&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Community metrics are yet not well defined and is a challenge for a lot of us. But the Orbit model streamlines these metrics and gives us a more efficient output. With this workflow, we now have our community metrics in our Orbit workspace. Orbit displays the data more sensibly and helps us understand the metrics. We can now make sense of these metrics and plan future activities for our community more efficiently.&lt;/p&gt;

&lt;p&gt;I’d love to hear about how you extend the workflow using n8n and manage your communities! If you run into any issues while following the tutorial, feel free to reach out to me on &lt;a href="https://twitter.com/harshil1712"&gt;Twitter&lt;/a&gt; or ask for help on our &lt;a href="https://community.n8n.io"&gt;forum&lt;/a&gt; 🧡&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;This &lt;a href="https://n8n.io/blog/migrating-community-metrics-to-orbit-using-n8n"&gt;post&lt;/a&gt; originally appeared on the n8n.io &lt;a href="https://n8n.io/blog/"&gt;blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>automation</category>
      <category>spreadsheets</category>
      <category>metrics</category>
      <category>community</category>
    </item>
    <item>
      <title>Building an expense tracking app in 10 minutes 📱</title>
      <dc:creator>Harshil Agrawal</dc:creator>
      <pubDate>Fri, 11 Dec 2020 07:42:06 +0000</pubDate>
      <link>https://dev.to/n8n/building-an-expense-tracking-app-in-10-minutes-jp7</link>
      <guid>https://dev.to/n8n/building-an-expense-tracking-app-in-10-minutes-jp7</guid>
      <description>&lt;p&gt;I tried various ways in the past to keep a record of my expenses. It started with maintaining a record in a book. But this wasn’t an ideal solution and involved manual work. I tried using everyone’s favorite — spreadsheets, but it failed me. I still had to manually enter the information of my expenses. I also tried various apps, but none of them catered to my needs. They all lacked the option to customize the way I wanted to upload the information.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--JUOJhPvD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/1%2ALU6439lmuKawTvBMf3Uyzw.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--JUOJhPvD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/1%2ALU6439lmuKawTvBMf3Uyzw.jpeg" alt="Building an expense tracking app in 10 minutes"&gt;&lt;/a&gt;&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;I built an app!&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;I came across the article &lt;a href="https://medium.com/n8n-io/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n-c47eb2f8d7a5"&gt;Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n 🧾&lt;/a&gt; that showed how to build a Telegram bot to track expenses. This article inspired me to create an app for managing my expenditures. But creating a mobile app would mean designing the user interface of the app and transforming the design into a functional app. This was going to be a long process, and I was looking for a less complicated and faster solution, and that is when I learnt about &lt;a href="https://www.bravostudio.app/"&gt;Bravo Studio&lt;/a&gt;. With Bravo Studio, you can convert your &lt;a href="https://www.figma.com"&gt;Figma&lt;/a&gt; designs into a mobile app. I had never used a no-code platform to build a mobile app, and Bravo Studio piqued my interest.&lt;/p&gt;

&lt;p&gt;I ended up creating an app that captures the image of a receipt and stores the information parsed from it in &lt;a href="http://airtable.com/"&gt;Airtable&lt;/a&gt;. I am now storing my data in my Airtable account, and I can use it the way I want. I would also have the flexibility to add more features that I might need in the future.&lt;/p&gt;

&lt;p&gt;If you’re struggling with managing expenses like me, and if you want to build your custom app, follow along with this article.&lt;/p&gt;

&lt;p&gt;In this article, we will learn to build an expense tracker app using no-code tools and platforms. We will start by designing our application in &lt;a href="https://www.figma.com"&gt;Figma&lt;/a&gt; and converting it into an app using &lt;a href="https://www.bravostudio.app/"&gt;Bravo Studio&lt;/a&gt;. We will use &lt;a href="https://www.typeform.com/"&gt;Typeform&lt;/a&gt; to collect the images of the receipts, &lt;a href="https://mindee.com/"&gt;Mindee&lt;/a&gt; to extract the information from the receipts, and &lt;a href="http://airtable.com/"&gt;Airtable&lt;/a&gt; to store all the information. We will use &lt;a href="https://n8n.io"&gt;n8n&lt;/a&gt; to connect these different services and make the app functional.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/zzqMt80B4CU"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Prerequisites&lt;/strong&gt;
&lt;/h3&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Figma&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Figma is a prototyping tool. We will use Figma to design the user interface of our application. If you don’t have an account on Figma, go ahead and &lt;a href="https://figma.com"&gt;create one&lt;/a&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Bravo Studio&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Bravo Studio is a no-code tool to build native applications. We will use Bravo Studio to transform our Figma design into a mobile app. &lt;a href="https://projects.bravostudio.app/signup"&gt;Create an account&lt;/a&gt; on Bravo Studio if you don’t have one. To test our application on a real device we will use &lt;a href="https://www.bravostudio.app/download-bravo-vision"&gt;Bravo Vision&lt;/a&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Typeform&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Our application needs an input field that will allow the users to upload images, as well as trigger our n8n workflow. We will use Typeform to handle that. &lt;a href="https://admin.typeform.com/signup"&gt;Create an account&lt;/a&gt; on Typeform and make a form with the question type ‘File Upload’.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; You need to have a premium account on Typefrom to access the ‘File Upload’ question type. If you don’t have a premium account, use the code n8nxTypeform_2021 to get free access for a month.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Mindee&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Once we have the image of the receipt, we need a service to extract the information from it. We will use Mindee to extract information like merchant name, amount, date of purchase, etc. from the image. You will need an &lt;a href="https://platform.mindee.net/signup"&gt;account&lt;/a&gt; on Mindee to access the Expense Receipts API.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Airtable&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Our app will use Airtable as the database to store and retrieve information from. We will need an &lt;a href="https://airtable.com/invite/r/J8pSjpKY"&gt;Airtable account&lt;/a&gt; to create a base and connect it with Bravo Studio. Sign in to your Airtable account and open &lt;a href="https://airtable.com/shrKmqHWcl3APJFlp"&gt;this base&lt;/a&gt;. Click on the &lt;strong&gt;&lt;em&gt;Copy base&lt;/em&gt;&lt;/strong&gt; button on the top right corner to copy the base in your account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you change the name of the columns, or the name of the Table, make sure to make the changes in the n8n workflow accordingly.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;n8n&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Our application is using different services like Typeform, Mindee and Airtable. n8n will connect these services together and make our app functional. Follow the instructions mentioned in the &lt;a href="https://docs.n8n.io/getting-started/quickstart.html#quickstart"&gt;documentation&lt;/a&gt; to install and spin-up an n8n instance on your machine. You can also sign-up for &lt;a href="https://n8n.cloud/"&gt;n8n.cloud&lt;/a&gt; to get early access to our hosted service.&lt;/p&gt;

&lt;p&gt;This workflow was built in n8n version 0.101.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Quickstart&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We often tend to jump to the results before committing to the steps of the experiment if we are aware of the subject. If you already know how to work with Bravo Studio and n8n, you can follow the quick-start instructions below to get to the endgame. If something is unclear or you want to learn more about how it works, feel free to dig deeper into the sections that follow.&lt;/p&gt;

&lt;h4&gt;
  
  
  Typeform
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Create a form in Typeform with the question type ‘File Upload’.&lt;/li&gt;
&lt;li&gt;Enter Upload Receipt in the text box where it indicates ‘Type question here…’.&lt;/li&gt;
&lt;li&gt;Publish the Typeform and copy the link under the ‘SHARE’ tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Figma
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;To duplicate &lt;a href="https://www.figma.com/file/w74DevtsNH1TenkwVRqQEH/Expense-Tracker"&gt;this Figma Design&lt;/a&gt; open it in a new tab.&lt;/li&gt;
&lt;li&gt;Click on the down chevron next to &lt;strong&gt;&lt;em&gt;Expense Tracker&lt;/em&gt;&lt;/strong&gt; and select ‘Duplicate to your Drafts’ from your dropdown list.&lt;/li&gt;
&lt;li&gt;Replace ‘TYPEFORM-LINK’ in Add Receipts Data &amp;gt; Typeform Container &amp;gt; Rectangle[component:web-view:TYPEFORM-LINK] with your form’s link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s---TYri4oz--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/954/0%2A5RJD-qne2N509q3t" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s---TYri4oz--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/954/0%2A5RJD-qne2N509q3t" alt=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  n8n
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://docs.n8n.io/getting-started/quickstart.html#quickstart"&gt;Install&lt;/a&gt; n8n, and copy and paste the workflow from the &lt;a href="https://n8n.io/workflows/741"&gt;workflow&lt;/a&gt; page. Configure the credentials for the following nodes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Typeform Trigger node (&lt;em&gt;Get Receipt&lt;/em&gt;): &lt;a href="https://docs.n8n.io/credentials/typeform/#using-access-token"&gt;&lt;em&gt;configure Access Token credentials&lt;/em&gt;&lt;/a&gt; and make sure to select your form from the &lt;strong&gt;&lt;em&gt;Form&lt;/em&gt;&lt;/strong&gt; dropdown list.&lt;/li&gt;
&lt;li&gt;Mindee node (&lt;em&gt;Extract Information&lt;/em&gt;): &lt;a href="https://docs.n8n.io/credentials/mindee"&gt;&lt;em&gt;configure credentials&lt;/em&gt;&lt;/a&gt; for the &lt;strong&gt;&lt;em&gt;Expense Receipts API.&lt;/em&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Airtable node (&lt;em&gt;Store Information&lt;/em&gt;): &lt;a href="https://docs.n8n.io/credentials/airtable"&gt;&lt;em&gt;configure credentials&lt;/em&gt;&lt;/a&gt; and enter the &lt;a href="https://airtable.com/api"&gt;application id&lt;/a&gt; and the table name in the &lt;strong&gt;&lt;em&gt;Base ID&lt;/em&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;em&gt;Table&lt;/em&gt;&lt;/strong&gt; field respectively.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Save the workflow and set it to active.&lt;/p&gt;

&lt;h4&gt;
  
  
  Bravo Studio
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Create an app in Bravo Studio by importing the Figma design from step 2.&lt;/li&gt;
&lt;li&gt;Add Airtable as a data library in Bravo Studio. You can follow the instructions mentioned in the official &lt;a href="https://www.notion.so/Using-the-Airtable-API-Wizard-f2e75dde9f13419f93661a9e116c1ac3"&gt;documentation&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Bind the data from your Airtable base to your Bravo Studio app. You can learn how to do that from the &lt;a href="https://www.notion.so/Bind-data-to-UI-for-a-list-page-2cf8c91380124ddf8b84d303e1c3ca8a"&gt;documentation&lt;/a&gt; of Bravo Studio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;NOTE&lt;/em&gt;&lt;/strong&gt; : If you were successfully able to follow the steps mentioned above, you can jump directly to the &lt;strong&gt;&lt;em&gt;Testing the Application&lt;/em&gt;&lt;/strong&gt; section before going through the conceptual sections.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Creating the User Interface&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;To add the expense in our app, a user will upload an image of the receipt. We will use a Typeform form to get the image of the receipt from the user. Create a form in Typeform with the question type ‘File Upload’. Publish the Typeform and copy the link under the ‘SHARE’ tab. We will use this link in Figma to embed the form.&lt;/p&gt;

&lt;p&gt;We want to create an app with our custom design and functionality. We will use Figma to design the prototype of the app. Below is the screenshot of the app’s design created in Figma.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--SvchL8X9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ACzdGvWSTuPVboOTr" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--SvchL8X9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ACzdGvWSTuPVboOTr" alt="Creating the app’s design in Figma"&gt;&lt;/a&gt;Creating the app’s design in Figma&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Our app will have three screens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A welcome screen which gives the user an option to either add a new receipt or view the stored receipts.&lt;/li&gt;
&lt;li&gt;An ‘Add Receipt Data’ screen that will allow the user to upload new receipts using the form we created in Typeform.&lt;/li&gt;
&lt;li&gt;A ‘Review Added Receipts’ screen to view all the receipts added by the user.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The design that we create in Figma should be compatible with Bravo Studio. To design a Bravo Studio app in Figma there are a few things to consider:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://www.notion.so/The-Container-how-to-set-up-any-Figma-file-for-Bravo-1af6df44b75f489ab47c0486e9984d91"&gt;Containerize your design&lt;/a&gt;: To make our Figma app design adaptable in Bravo Studio, wrap the layers in containers. These containers are frames that divide an app screen into separate blocks based on the intended behavior of the design. In our app, we will need to create the following containers:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Main Container&lt;/strong&gt; : The &lt;strong&gt;&lt;em&gt;Welcome Screen&lt;/em&gt;&lt;/strong&gt; contains a container that consists of all the layers and the ‘App Buttons’ container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s---oKQ8cne--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2A9wEdGtjyvwr0xU3P" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s---oKQ8cne--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2A9wEdGtjyvwr0xU3P" alt="Creating the Main Container"&gt;&lt;/a&gt;&lt;em&gt;Creating the Main Container&lt;/em&gt;&lt;p&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App Buttons&lt;/strong&gt; : This container consists of the ‘Add new receipt’ and ‘My receipts’ button on the &lt;strong&gt;&lt;em&gt;Welcome Screen&lt;/em&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--iNrtd_Oq--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AO8L5HeLQAFnivtdD" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--iNrtd_Oq--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2AO8L5HeLQAFnivtdD" alt="Creating the App Buttons container"&gt;&lt;/a&gt;&lt;em&gt;Creating the App Buttons container&lt;/em&gt;&lt;p&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Top navigation&lt;/strong&gt; : We need a container that helps us navigate back to the welcome screen. The Top navigation container will contain an image for the back button and the title of the screen. We will add this container to the &lt;strong&gt;&lt;em&gt;Add Receipt Data&lt;/em&gt;&lt;/strong&gt; screen and the &lt;strong&gt;&lt;em&gt;Review Added Receipts&lt;/em&gt;&lt;/strong&gt;  screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--cr7VyLY9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2Ar9CHDNvLghr5CxRm" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--cr7VyLY9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2Ar9CHDNvLghr5CxRm" alt="Creating the Top navigation container"&gt;&lt;/a&gt;&lt;em&gt;Creating the Top navigation container&lt;/em&gt;&lt;p&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Typeform Container&lt;/strong&gt; : The Typeform Container contains a rectangle layer that will render the Typeform. We will add this container to the ‘Add new receipt’ screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--Y9DTytEh--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ARdFIl2pEPTqSMRJE" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--Y9DTytEh--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ARdFIl2pEPTqSMRJE" alt="Creating the Typeform container"&gt;&lt;/a&gt;&lt;em&gt;Creating the Typeform container&lt;/em&gt;&lt;p&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;*List: This container consists of the layers that will render the receipts information such as the merchant name, amount, date, time, category, and image of the receipt. We will add this container to the ‘My receipts’ screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--c9mwknJC--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2A5HJfkL2t99trUueW" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--c9mwknJC--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2A5HJfkL2t99trUueW" alt="Creating the *List container"&gt;&lt;/a&gt;&lt;em&gt;Creating the *List container&lt;/em&gt;&lt;p&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;a href="https://www.notion.so/Bravo-Tags-Master-List-145bec845f0b4afaa9e3bb8321b218a8"&gt;Bravo Tags&lt;/a&gt;: A Bravo Tag is a text string that converts the layer into a mobile component. For each container and layer, you want to convert to a mobile component, use the appropriate Bravo Tags. In our design, we will use the &lt;a href="https://www.notion.so/Component-Web-view-909829539eea4a92892a1fa41bd8934e"&gt;[component:web-view:URL]&lt;/a&gt; tag to embed the Typeform in the &lt;em&gt;Typeform Container&lt;/em&gt;. Replace ‘URL’ with the URL of the Typeform you created earlier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The name of the List container and the layers inside the container starts with an asterisk (*). Bravo Studio recognizes such containers and layers as a list.&lt;/p&gt;

&lt;p&gt;After designing the application in Figma, the next step is to use this design in Bravo Studio to create a native application. In Bravo Studio, click on the &lt;strong&gt;&lt;em&gt;Import Figma File&lt;/em&gt;&lt;/strong&gt; button and copy and paste the link of your Figma design in the input field to create a new project.&lt;/p&gt;

&lt;p&gt;Congratulations! The user-interface of your application is ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Connecting with Airtable&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;In Bravo Studio we can use a custom API or Airtable to connect our application with a database. The application we built uses Airtable as the database. Hence, we will connect Airtable with our application.&lt;/p&gt;

&lt;p&gt;In Bravo Studio, add a new data collection by clicking the ‘+ New Collection’ button on the Data Library page. Select Airtable, and follow the instructions as shown in the video, in the Airtable API Wizard, enter the base URL and your Airtable API Key. You can learn more about &lt;em&gt;Using the Airtable API Wizard&lt;/em&gt; from the documentation &lt;a href="https://www.notion.so/Using-the-Airtable-API-Wizard-f2e75dde9f13419f93661a9e116c1ac3"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Click on &lt;strong&gt;&lt;em&gt;Advanced view&lt;/em&gt;&lt;/strong&gt; and make a GET request for ‘Expenses — List’. Select ‘All’ from the data.records[] dropdown list in the &lt;strong&gt;&lt;em&gt;Response Data&lt;/em&gt;&lt;/strong&gt;  section.&lt;/p&gt;

&lt;p&gt;Navigate to the ‘Projects’ page and select the app that we created. We will bind the data from the Airtable base with the respective components of the ‘Review Added Receipts’ screen. Select the ‘Review Added Receipts’ screen and select the ‘Expenses Tracker’ Airtable base. Select ‘GET Expenses — List’ in the &lt;strong&gt;&lt;em&gt;Select Request&lt;/em&gt;&lt;/strong&gt; section of &lt;strong&gt;&lt;em&gt;Data Binding&lt;/em&gt;&lt;/strong&gt;. Select the *List component and select Records from the dropdown list below &lt;strong&gt;&lt;em&gt;Get Expenses — List&lt;/em&gt;&lt;/strong&gt;. Expand the *List component and map each element to their corresponding field. You can learn more about this from the &lt;a href="https://www.notion.so/Bind-data-to-UI-for-a-list-page-2cf8c91380124ddf8b84d303e1c3ca8a"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The video below shows the steps to bind the data to components.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/6jxIq1lQW_8"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Building the workflow&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Creating a backend for applications can get complicated when it comes to connecting different services together. You have to write functions to make API calls to each service, and pass information from one service to another in the proper format. But n8n makes it easy to create the backend and stitch different services together. You also get a visual representation that helps you in designing the application logic.&lt;/p&gt;

&lt;p&gt;In n8n, you will use the following nodes to build the backend logic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.typeformTrigger/#typeform-trigger"&gt;Typeform Trigger&lt;/a&gt; node (Get Receipt)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.httpRequest/#http-request"&gt;HTTP Request&lt;/a&gt; node (Get Image)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.mindee/#mindee"&gt;Mindee&lt;/a&gt; node (Extract Information)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.set/#set"&gt;Set&lt;/a&gt; node (Set Information)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.airtable/#airtable"&gt;Airtable&lt;/a&gt; node (Store Information)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Our workflow will look like the image below.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--ZV5C62DN--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ACkc3jg0WaYHkHfHZ" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--ZV5C62DN--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/1024/0%2ACkc3jg0WaYHkHfHZ" alt=""&gt;&lt;/a&gt;Completed workflow in n8n&lt;p&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;1. Typeform Trigger node (Get Receipt)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The Typeform Trigger node will trigger the workflow whenever the user submits a new receipt. Add the Typeform Trigger node and configure the credentials by following the instructions in the documentation &lt;a href="https://docs.n8n.io/credentials/typeform/#prerequisites"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Select the form that you created for the application from the &lt;strong&gt;&lt;em&gt;Form&lt;/em&gt;&lt;/strong&gt; dropdown list.&lt;/p&gt;

&lt;p&gt;Rename the node as Get Receipt, and save the workflow.&lt;/p&gt;

&lt;p&gt;Click on the &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; button to execute the node. Upload an image of a receipt using the Typeform. You will notice that the Typeform Trigger node triggers the workflow when the form is submitted.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/DzF1JBU1k9A"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;2. HTTP Request node (Get Image)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The HTTP Request node is used to make an HTTP request to APIs and websites. It can return data in JSON, String as well as Binary format. Add an HTTP Request node in n8n that will make a GET request to fetch the image we uploaded using the Typeform. In the URL parameter of the node, add the following expression: &lt;code&gt;{{$node["Get Receipt"].json["Upload receipt"]}}&lt;/code&gt;. This expression will get the URL of the image that we uploaded using Typeform.&lt;/p&gt;

&lt;p&gt;Select ‘File’ from the &lt;strong&gt;&lt;em&gt;Response Format&lt;/em&gt;&lt;/strong&gt; dropdown list and rename the node to Get Image. Click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node. In the output, you will notice that the node returns the image of the receipt.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/J_o9ThCtAfE"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;3. Mindee node (Extract Information)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The Mindee API enables to extract information from an image and transform it into usable data. We will use the Mindee node in n8n to extract data from an image of a receipt. The node will extract the merchant name, the amount, the date and time the expense was incurred, and its category.&lt;/p&gt;

&lt;p&gt;Add the Mindee node in the n8n workflow, and configure the credentials as mentioned in the &lt;a href="https://docs.n8n.io/credentials/mindee/#prerequisites"&gt;documentation.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rename the node to Extract Information, and click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node. You will notice that the node extracts the information from the image and transforms it into usable data that can be added to Airtable.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/fGkDLKit2Y0"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;4. Set node (Set Information)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The Set node allows us to set the workflow data. We will reference the data from the Mindee node and the Typeform Trigger node in the Set node and set the parameters.&lt;/p&gt;

&lt;p&gt;Add the Set node in your n8n workflow and add the parameters with the following properties.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amount&lt;/strong&gt; : The type of this field should be &lt;em&gt;Number&lt;/em&gt; and it’s value should be &lt;code&gt;{{$node["Extract Information"].json["total"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merchant&lt;/strong&gt; : The type of this field should be &lt;em&gt;String&lt;/em&gt; and it’s value should be &lt;code&gt;{{$node["Extract Information"].json["merchant"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date&lt;/strong&gt; : The type of this field should be &lt;em&gt;String&lt;/em&gt; and it’s value should be &lt;code&gt;{{$node["Extract Information"].json["date"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time&lt;/strong&gt; : The type of this field should be &lt;em&gt;String&lt;/em&gt; and it’s value should be &lt;code&gt;{{$node["Extract Information"].json["time"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receipt&lt;/strong&gt;  &lt;strong&gt;URL&lt;/strong&gt; : The type of this field should be &lt;em&gt;String&lt;/em&gt; and it’s value should be &lt;code&gt;{{$node["Get Receipt"].json["Upload receipt"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category&lt;/strong&gt; : The type of this field should be &lt;em&gt;String&lt;/em&gt; and it’s value should be &lt;code&gt;{{$node["Extract Information"].json["category"]}}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The value in the &lt;strong&gt;&lt;em&gt;Name&lt;/em&gt;&lt;/strong&gt; field should be the same as the column name of your Airtable base.&lt;/p&gt;

&lt;p&gt;Toggle the &lt;strong&gt;&lt;em&gt;Keep Only Set&lt;/em&gt;&lt;/strong&gt; to true. This ensures that only the data that we set in this node gets passed on to the next nodes in the workflow. Click on the &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; button to run the node.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/olckiEY_JP0"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;5. Airtable node (Store Information)&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Airtable is a spreadsheet-database hybrid, with the features of a database but applied to a spreadsheet. The Bravo Studio app will get the data from the Airtable base to display it to the user. But before connecting the app with Airtable, in the front-end, we need to connect Airtable with your backend.&lt;/p&gt;

&lt;p&gt;The n8n workflow will store the data extracted by the Mindee node and the URL of the image in Airtable. Create a base in Airtable similar to this &lt;a href="https://airtable.com/shrKmqHWcl3APJFlp"&gt;template base&lt;/a&gt; if you haven’t already created one.&lt;/p&gt;

&lt;p&gt;In n8n, add the Airtable node and configure the credentials following the instructions mentioned &lt;a href="https://docs.n8n.io/credentials/airtable/#prerequisites"&gt;here&lt;/a&gt;. Select ‘Append’ from the &lt;strong&gt;&lt;em&gt;Operation&lt;/em&gt;&lt;/strong&gt; dropdown list. To get the Application ID, go to &lt;a href="https://airtable.com/api"&gt;https://airtable.com/api&lt;/a&gt; and select the base. Copy and paste the ID in the &lt;strong&gt;&lt;em&gt;Base ID&lt;/em&gt;&lt;/strong&gt; field in n8n. Enter the name of your base in the &lt;strong&gt;&lt;em&gt;Table&lt;/em&gt;&lt;/strong&gt;  field.&lt;/p&gt;

&lt;p&gt;Click on &lt;strong&gt;&lt;em&gt;Execute Node&lt;/em&gt;&lt;/strong&gt; to run the node. On successful execution, you will notice that a new row gets added to your Airtable base.&lt;/p&gt;

&lt;p&gt;&lt;iframe width="710" height="399" src="https://www.youtube.com/embed/SXxwtQ8ShkU"&gt;
&lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Testing the application&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Congratulations! You have successfully built the application. Now let us test our application and see how it would work in the real world.&lt;/p&gt;

&lt;p&gt;In n8n, on the top right corner, toggle &lt;strong&gt;&lt;em&gt;Active&lt;/em&gt;&lt;/strong&gt; to true. This will set our workflow to active and execute the workflow every time a new receipt is submitted to Typeform.&lt;/p&gt;

&lt;p&gt;Open the Bravo Vision app on your device and select the Expense Tracker app. Click on the &lt;strong&gt;&lt;em&gt;Add new receipt&lt;/em&gt;&lt;/strong&gt; button and submit an image of the receipt.&lt;/p&gt;

&lt;p&gt;If you don’t have a receipt at hand now, feel free to use the following image of the receipt for testing.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--OhqIOUcY--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/960/0%2Aqqco5Y1mCAtm1vga" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--OhqIOUcY--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/960/0%2Aqqco5Y1mCAtm1vga" alt="Image of a receipt"&gt;&lt;/a&gt;A receipt for testing your workflow&lt;p&gt;&lt;/p&gt;

&lt;p&gt;You can see the new receipt and the information on the ‘My Receipts’ screen.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;a href="https://res.cloudinary.com/practicaldev/image/fetch/s--XxG1m0oL--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/720/0%2AUSKl2aoTNeDR2lmD" class="article-body-image-wrapper"&gt;&lt;img src="https://res.cloudinary.com/practicaldev/image/fetch/s--XxG1m0oL--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://cdn-images-1.medium.com/max/720/0%2AUSKl2aoTNeDR2lmD" alt="Viewing the receipt in Bravo Vision"&gt;&lt;/a&gt;My Receipts page after adding the receipt&lt;p&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What’s next?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;You can extend the workflow and send an email every time you add a new expense, using either the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.emailSend/"&gt;Send Email&lt;/a&gt; node or the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.gmail/#gmail"&gt;Gmail&lt;/a&gt; node. You can also use the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.twilio/#twilio"&gt;Twilio&lt;/a&gt; node or the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.vonage/#vonage"&gt;Vonage&lt;/a&gt; node to send an SMS when a new expense gets added.&lt;/p&gt;

&lt;p&gt;You can also create a new workflow that will send you a weekly or monthly notification about the expenses you made during that period. This workflow can be built using the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.cron/#cron"&gt;Cron&lt;/a&gt; node to trigger the workflow weekly or monthly, the Airtable node to read the data, and the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.function/#function"&gt;Function&lt;/a&gt; node to calculate the sum of the expenses. To send a push notification, you can use the &lt;a href="https://docs.n8n.io/nodes/n8n-nodes-base.pushover/#pushover"&gt;Pushover&lt;/a&gt; node.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Summary&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;In this article, you learned how to build an application. You learned to design an app in Figma that is compatible with Bravo Studio. You also created the front-end of the app using Bravo Studio. You stitched different services together using n8n to get the desired output. Lastly, you learned how to bind data to your app in Bravo Studio.&lt;/p&gt;

&lt;p&gt;I plan to add more features to the app that will help me build a healthy habit of keeping track of my expenses and help me manage them.&lt;/p&gt;

&lt;p&gt;I’d love to hear about how you extend the app using n8n! If you run into any issues while following the tutorial, feel free to me on &lt;a href="https://twitter.com/harshil1712"&gt;Twitter&lt;/a&gt; or ask for help on our &lt;a href="https://community.n8n.io"&gt;forum&lt;/a&gt; 🧡&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Further Reading&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;If you would like to dig deeper and learn more about the topics discussed in the article, consult the following resources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.notion.so/Setting-up-your-Figma-file-4a4fa15121284c34a9618e5246a0d7eb"&gt;Setting up your Figma File&lt;/a&gt;: You can read more about making a Bravo Studio compatible Figma design on Bravo Studios’ official documentation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.notion.so/Bravo-Tags-Master-List-145bec845f0b4afaa9e3bb8321b218a8"&gt;Bravo Tags Master List&lt;/a&gt;: Bravo Studio also has various other Bravo Tags that you can use to add more components to your app.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.n8n.io"&gt;n8n documentation&lt;/a&gt;: Check out the official documentation of n8n for more tutorials and reference information&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://n8n.io/integrations"&gt;n8n integrations&lt;/a&gt;: You can extend the functionality of your app by different nodes. Check out the list of all the integrations that n8n provides.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://n8n.io/workflows"&gt;n8n workflows&lt;/a&gt;: n8n has an extensive library of workflows ready for you to use.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://medium.com/n8n-io/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n-c47eb2f8d7a5"&gt;Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n&lt;/a&gt;: If you are interested in building a Telegram bot instead of the application, check out this article.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This &lt;a href="https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/"&gt;post&lt;/a&gt; originally appeared on the n8n.io &lt;a href="https://n8n.io/blog/"&gt;blog&lt;/a&gt;.&lt;/p&gt;




</description>
      <category>n8n</category>
      <category>typeform</category>
      <category>airtable</category>
      <category>apps</category>
    </item>
  </channel>
</rss>
