<?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: Will Dady</title>
    <description>The latest articles on DEV Community by Will Dady (@willdady).</description>
    <link>https://dev.to/willdady</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%2F85994%2F45bf4e1c-5668-4c83-92eb-a67dcd02cb80.jpeg</url>
      <title>DEV Community: Will Dady</title>
      <link>https://dev.to/willdady</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/willdady"/>
    <language>en</language>
    <item>
      <title>I built Platypus, an AI agent platform, because chat interfaces aren't enough</title>
      <dc:creator>Will Dady</dc:creator>
      <pubDate>Mon, 06 Jul 2026 01:30:00 +0000</pubDate>
      <link>https://dev.to/willdady/i-built-platypus-an-ai-agent-platform-because-chat-interfaces-arent-enough-4p21</link>
      <guid>https://dev.to/willdady/i-built-platypus-an-ai-agent-platform-because-chat-interfaces-arent-enough-4p21</guid>
      <description>&lt;p&gt;I woke up last Tuesday to find my news dashboard already updated, four research topics processed into structured Obsidian notes, and a stale deploy cleaned up automatically. No human touched any of it. The agents handled everything overnight.&lt;/p&gt;

&lt;p&gt;This is &lt;a href="https://platypus.chat" rel="noopener noreferrer"&gt;Platypus&lt;/a&gt;, an open-source platform I've been building since November 2025. It's not a chatbot. It's not a prompt playground. It's a self-hosted system where AI agents run on schedules, react to events, delegate work to each other, and remember what you care about across conversations.&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%2Fgqkkig20purv9wyiwsip.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%2Fgqkkig20purv9wyiwsip.png" alt="Screenshot of the Platypus workspace home page" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post covers what it is, how it works, and the trade-offs I made along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why chat-first AI tools fall apart for autonomous work
&lt;/h2&gt;

&lt;p&gt;Most open-source AI interfaces are built around a single interaction pattern: you type a message, you get a response, maybe you attach a file. That's fine for one-off questions, but it breaks down the moment you want agents operating independently.&lt;/p&gt;

&lt;p&gt;I wanted agents that could process a research queue every hour, sync results into my knowledge base, post a daily news summary, and clean up after themselves on a schedule. I wanted them to collaborate, with a parent agent delegating a research task to a specialist, getting the result back, then handing the write-up to another specialist. I wanted persistent memory so an agent remembers my preferences without me restating them every session.&lt;/p&gt;

&lt;p&gt;Tools like Open WebUI are excellent chat interfaces. But they're fundamentally conversation-centric. Platypus is action-first. The chat interface exists, but it's one of many surfaces. The real power is in what agents do when nobody's watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent model: agents, skills, sub-agents, and memory
&lt;/h2&gt;

&lt;p&gt;At the core of Platypus is a set of primitives that compose together. Rather than one monolithic "assistant," you build specialised agents and wire them into workflows.&lt;/p&gt;

&lt;p&gt;An agent pins a provider, model, system prompt, tools, skills, and sub-agents into a reusable configuration. Select one in a chat and all of that loads. But agents also run headlessly via triggers, which is where things get interesting.&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%2F6agoapnlw020qan7ow6d.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%2F6agoapnlw020qan7ow6d.png" alt="Screenshot of the Platypus agents list" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Skills work like they do in Claude Code or OpenCode. I have a &lt;code&gt;blog-writing&lt;/code&gt; skill, a &lt;code&gt;humaniser&lt;/code&gt; skill, one that teaches agents how to write good trigger instructions. The agent decides whether it needs a skill and requests it at runtime. You don't front-load everything into the system prompt.&lt;/p&gt;

&lt;p&gt;Sub-agents let a parent hand off a task to a specialist with an isolated context and get the result streamed back. This is how I run complex workflows without bloating a single agent's context window with unrelated instructions. The parent doesn't need to know how the specialist works, just what to ask for.&lt;/p&gt;

&lt;p&gt;Memory extracts facts and preferences from conversations in the background and injects them into future chats. The agent remembers your timezone, your project names, your preferences. Embeddings are generated from memories so agents can search for semantically related context from previous conversations, not just exact keyword matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The automation layer: triggers, boards, sandboxes, and dashboards
&lt;/h2&gt;

&lt;p&gt;The second half of the system is about what happens without human involvement.&lt;/p&gt;

&lt;p&gt;Triggers fire agents automatically. Cron triggers run on schedules (my news agent fires at 6:15am daily). Event triggers react to system events, so when a Kanban card moves to a specific column, the appropriate agent picks it up.&lt;/p&gt;

&lt;p&gt;Kanban boards give agents a task management surface. Create cards, move them between columns, update descriptions. Combined with event triggers, this creates autonomous pipelines where agents process work items end to end.&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%2Fww37fl9d92heiz2lmowp.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%2Fww37fl9d92heiz2lmowp.png" alt="Screenshot of the Platypus Kanban board" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sandboxes give agents shell and filesystem access inside an isolated, per-workspace container. They can run arbitrary commands, write files, install packages. The backend is pluggable. Docker ships as the reference implementation, and the adapter interface is designed for contributions of other backends like E2B or Daytona.&lt;/p&gt;

&lt;p&gt;Dashboards let agents surface data visually through metric widgets, charts, markdown panels, and weather displays. Agents update widget data autonomously via built-in tools. My news agent, for example, writes a markdown summary to a dashboard widget every morning before I'm awake.&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%2Fvqv7tl5g8bx7ki9iwxqh.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%2Fvqv7tl5g8bx7ki9iwxqh.png" alt="Screenshot of the Platypus Dashboard" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TypeScript all the way down: Next.js, Hono, and Drizzle
&lt;/h2&gt;

&lt;p&gt;The frontend is Next.js with ShadCN and Tailwind CSS, streaming responses via the Vercel AI SDK. The backend is a Hono.js REST API on Node.js handling agent logic, tool execution, and database interactions with PostgreSQL through Drizzle ORM. Shared Zod schemas between the two provide end-to-end type safety without manual synchronisation.&lt;/p&gt;

&lt;p&gt;I chose Hono because it's fast, typed, and runs anywhere. Drizzle because I need raw SQL escape hatches for pgvector queries without fighting the ORM. Next.js on the frontend because, love it or hate it, the App Router and Server Components model works well for this kind of app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-tenancy from day one
&lt;/h3&gt;

&lt;p&gt;Platypus has Organisations and Workspaces baked in from the start. An Organisation owns Workspaces, Providers, and member roles. Each Workspace is a scoped environment containing its own Chats, Agents, Skills, MCPs, and optionally a Sandbox.&lt;/p&gt;

&lt;p&gt;I built it this way because I run multiple isolated contexts myself (personal research, project management, writing) and because multi-tenancy bolted on later is a nightmare. The data isolation is real, not just a UI filter over a shared table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Provider agnosticism through the Vercel AI SDK
&lt;/h3&gt;

&lt;p&gt;Rather than coupling to a single LLM provider, Platypus uses the Vercel AI SDK as an abstraction layer. You configure Providers at the Organisation or Workspace level with credentials, a base URL, and a list of enabled model IDs. Swap between Anthropic, OpenAI, Google, or a Bedrock-hosted provider without changing agent configurations.&lt;/p&gt;

&lt;p&gt;Providers can also designate a cheaper model for one-shot operations like generating chat titles or extracting memory. You don't want to burn frontier-model tokens on metadata tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  How agents connect to external services via MCP
&lt;/h2&gt;

&lt;p&gt;Platypus has first-class support for the Model Context Protocol. You register MCP servers at the Organisation or Workspace level and they resolve to tool sets at chat-turn time. This is how my agents interact with external services without me writing custom tool implementations for each one.&lt;/p&gt;

&lt;p&gt;The MCP integration means Platypus grows its capabilities without bespoke tools for every service. If an MCP server exists for it, an agent can use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real workflow: my automated research pipeline
&lt;/h2&gt;

&lt;p&gt;Here's a pipeline I run daily that touches most of these primitives:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I ask a general chat coordinator agent to add some research topics, which it delegates to a dedicated Kanban agent that creates the cards in the board's "To Do" column.&lt;/li&gt;
&lt;li&gt;An hourly cron trigger fires a research agent that picks up cards from "To Do," performs web research, writes findings to the card, and moves it to "Note Processing."&lt;/li&gt;
&lt;li&gt;An event trigger watches for cards entering "Note Processing" and fires an Obsidian agent that creates a structured note in my vault with proper wiki-links, frontmatter, and cross-references, then moves the card to "Done."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No human intervention after step 1. The agents handle the rest, including error recovery and retries.&lt;/p&gt;

&lt;p&gt;Each agent in this pipeline runs on a provider and model suited to its job. The research agent uses a capable frontier model because it's reasoning over web results and synthesising findings. The Obsidian agent, by contrast, runs on a cheaper, less intelligent model since it's mostly shuttling pre-written content into the right place in my vault. Because providers and models are configured per agent, I'm not paying frontier-model rates for what amounts to structured file shuffling.&lt;/p&gt;

&lt;p&gt;I also run the daily news summary agent I mentioned earlier. A separate cleanup trigger purges old news cards every two days so the board doesn't accumulate cruft. The whole system is surprisingly low-maintenance once the triggers are configured correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  No RAG, no multiplayer, no hosted version
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about what Platypus deliberately doesn't do.&lt;/p&gt;

&lt;p&gt;There's no built-in RAG pipeline. Vector search is there (pgvector) and I use embeddings for memory retrieval. But a general purpose document ingestion system is a product unto itself. Instead, agents use sandboxes and MCP tools to process documents however they need to. The flexibility of "just run a script in the sandbox" beats a rigid pipeline for my use cases.&lt;/p&gt;

&lt;p&gt;There's no real-time collaboration. Workspaces are single-user. Adding multiplayer would require CRDTs, presence, conflict resolution. The primary use case is personal agent orchestration, not team chat. Multi-tenancy gives you isolation between contexts, not collaboration within them.&lt;/p&gt;

&lt;p&gt;There's no hosted offering. Platypus is self-hosted only. Docker Compose is the reference deployment story, and since everything ships as containers, any container platform works too (Kubernetes, ECS, and so on). You need a server, but you own your data entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's experimental and what's next
&lt;/h2&gt;

&lt;p&gt;Platypus is a work in progress. I ship features most weeks. The sandbox system is experimental. Dashboards are experimental. Sub-agent delegation works but the developer experience can improve.&lt;/p&gt;

&lt;p&gt;The pace of change is still high, with additional sandbox backends beyond Docker and improvements to the memory system on the roadmap.&lt;/p&gt;

&lt;p&gt;The project is MIT licensed and contributions are welcome. If you're the kind of developer who wants AI agents that go beyond chat, that run autonomously, manage tasks, and integrate into your existing tools, I'd genuinely appreciate the help. Bug fixes, new sandbox backends, MCP integrations, ideas I haven't thought of.&lt;/p&gt;

&lt;p&gt;Clone the repo, run &lt;code&gt;docker compose up&lt;/code&gt;, and you'll have a workspace running in under two minutes. Head to &lt;a href="https://platypus.chat" rel="noopener noreferrer"&gt;platypus.chat&lt;/a&gt; to get started, and if you'd like to contribute, the source lives at &lt;a href="https://github.com/willdady/platypus" rel="noopener noreferrer"&gt;github.com/willdady/platypus&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Platypus logo by &lt;a href="https://thiings.co" rel="noopener noreferrer"&gt;Thiings.co&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>typescript</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Getting to the meat and potatoes of serverless recipe parsing with Amazon Bedrock</title>
      <dc:creator>Will Dady</dc:creator>
      <pubDate>Sat, 20 Jul 2024 05:28:25 +0000</pubDate>
      <link>https://dev.to/willdady/getting-to-the-meat-and-potatoes-of-serverless-recipe-parsing-with-amazon-bedrock-4ifc</link>
      <guid>https://dev.to/willdady/getting-to-the-meat-and-potatoes-of-serverless-recipe-parsing-with-amazon-bedrock-4ifc</guid>
      <description>&lt;p&gt;If you're like me you've probably visited a few recipe websites in your time and had the unfortunate experience of having to scroll through the author's life story before getting to the actual recipe. I recently stumbled across an interesting website which is able to take a URL and extract just the recipe without all the surrounding fluff. This got me thinking... could I build something similar on AWS with serverless technologies and generative AI?&lt;/p&gt;

&lt;p&gt;Lately I have been experimenting with various large language models on &lt;a href="https://aws.amazon.com/bedrock/" rel="noopener noreferrer"&gt;Amazon Bedrock&lt;/a&gt;. I have been particularly interested in the latest offering from Anthropic with it's &lt;a href="https://www.anthropic.com/claude" rel="noopener noreferrer"&gt;Claude 3 family of models&lt;/a&gt;. My idea was to see if I could use Claude to extract a recipe from a web page and return it as a structured JSON object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crafting the prompt
&lt;/h2&gt;

&lt;p&gt;I hypothesised that Anthropic's Hiaku model would make light work of finding a recipe in a body of text assuming it is given a well crafted prompt. Luckily, Anthropic's &lt;a href="https://docs.anthropic.com/en/docs/prompt-engineering" rel="noopener noreferrer"&gt;prompt engineering documentation&lt;/a&gt; provides some excellent guidance on how best to craft a prompt to work with their family of LLMs.&lt;/p&gt;

&lt;p&gt;I began with some low-fi manual testing directly in the AWS Bedrock Chat Playground. I figured my eventual solution would scrape HTML from a target website and inject it into the prompt but in the meantime I'd need to do this manually. This is as simple as navigating to a recipe in my browser, viewing the page source and copying the raw HTML. I then paste the raw HTML into my prompt with some surrounding instructional text.&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%2Fstatic.willdady.com%2Fimages%2F2024%2F05%2Fbedrock-screenshot_1917x811.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%2Fstatic.willdady.com%2Fimages%2F2024%2F05%2Fbedrock-screenshot_1917x811.png" alt="The Amazon Bedrock Chat Playground showing a well-formed JSON response from Claude" width="799" height="338"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;The Amazon Bedrock Chat Playground showing a well-formed JSON response from Claude
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;After some experimentation in the Bedrock console I was able to get Claude to consistently parse a recipe from a body of text and return it as structured JSON. Pretty cool!&lt;/p&gt;

&lt;p&gt;I ended up settling on the following prompt template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read the HTML fragment contained inside the following &amp;lt;document&amp;gt;&amp;lt;/document&amp;gt; XML tags and extract the recipe.

&amp;lt;document&amp;gt;
&amp;lt;%- document %&amp;gt;
&amp;lt;/document&amp;gt;

If you are able to successfully find a recipe in the document output your result as a JSON object with the following fields:

title: A string containing the receipe title
description: A string containing a summary of what the recipe makes
ingredients: An array of strings where each is a single ingredient
steps: An array of strings where each describes a step required to complete the recipe

&amp;lt;example&amp;gt;
{
  "title": "Pasta Aglio E Oilio",
  "description": "Pasta Aglio e Olio is a classic Italian dish known for its simplicity and flavor. Translating to 'pasta with garlic and oil' it's a traditional recipe originating from the region of Naples. The dish features spaghetti cooked al dente and then tossed with minced garlic, olive oil, red pepper flakes, and sometimes parsley. Despite its minimal ingredients, Pasta Aglio e Olio packs a punch of flavor, with the garlic-infused oil coating each strand of pasta for a deliciously satisfying meal. It's a go-to dish for a quick, easy, and tasty dinner option.",
  "ingredients": [
    "Half a Lemon",
    "Fresh Grated Parmesan",
    "2 - 3 Tbsp Butter",
    "1 Cup Chopped Italian Parsley",
    "1 Pinch Chili Flakes or Hot Pepper FlakesFresh",
    "Ground Pepper",
    "2 Tbsp Salt (for pasta water)",
    "3 Cloves Thin Sliced Fresh Garlic",
    "1 Cup Pasta Water",
    "1/4 Cup Olive Oil",
    "450g Spaghetti"
  ],
  "steps": [
    "Slice garlic cloves thin (do not chop) and remove stems from parsley. Chop parsley fine and set all this aside. Have all your ingredients handy and ready since you will need to get to them quickly.",
    "Bring water with 2 TBSP salt to a boil and add pasta. Cook for 10-12 minutes till al dente. Save 2 cups of the hot pasta water (for possibly creating more liquid) before draining.",
    "About 5 minutes before your pasta is ready, heat the olive oil in large pan on medium and add the sliced garlic. Toss gently and after about 30 seconds add the hot pepper flakes and salt and pepper. Cook and toss for about 30 seconds.",
    "Add parsley, cooked pasta, pasta water to your liking, butter and squeeze in the half lemon (watch for seeds). Stir gently and bring to gentle boil. Add more fresh pepper and salt to taste. Toss the mixture well to fully coat and turn off heat.",
    "Let mixture set about 30 seconds so liquid thickens. Serve hot and top with fresh grated parmesan."
  ]
}
&amp;lt;/example&amp;gt;

If you are not able to find a recipe in the document respond with a JSON object with the following fields:

error: A string stating "No recipe found in document"
cause: A string describing the error

&amp;lt;example&amp;gt;
{
  "error": "No recipe found in document",
  "cause": "The document appears to be about Harley Davidson motocycles"
}
&amp;lt;/example&amp;gt;

&amp;lt;example&amp;gt;
{
  "error": "No recipe found in document",
  "cause": "The document appears to be about cats"
}
&amp;lt;/example&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take note of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I am using XML tags to delineate parts of the prompt as recommended in Anthropic's documentation - &lt;a href="https://docs.anthropic.com/en/docs/use-xml-tags" rel="noopener noreferrer"&gt;Use XML tags&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;&amp;lt;document&amp;gt;&lt;/code&gt; tags is where you put the HTML you want Claude to read. 
The &lt;code&gt;&amp;lt;%- document %&amp;gt;&lt;/code&gt; contained within is an &lt;a href="https://ejs.co/" rel="noopener noreferrer"&gt;ejs&lt;/a&gt; placeholder. 
More on this shortly.&lt;/li&gt;
&lt;li&gt;I am using &lt;code&gt;&amp;lt;example&amp;gt;&lt;/code&gt; tags to show how I want the JSON to be structured.
I'm also telling Claude what to do if it is unable to find a recipe in the document.&lt;/li&gt;
&lt;li&gt;I am explicitly asking for JSON output.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Building a solution with the AWS CDK
&lt;/h2&gt;

&lt;p&gt;Now that I have a prompt which I am satisfied works, I set out to automate it.&lt;br&gt;
I began by creating a new &lt;a href="https://aws.amazon.com/cdk/" rel="noopener noreferrer"&gt;AWS CDK&lt;/a&gt; project using Typescript.&lt;/p&gt;

&lt;p&gt;The bulk of the solution will be driven by one of my favourite serverless AWS services, &lt;a href="https://aws.amazon.com/step-functions/" rel="noopener noreferrer"&gt;Step Functions&lt;/a&gt;! Using the CDK I create a Step Functions state machine with it's type set to &lt;em&gt;Express&lt;/em&gt;. We need to use the Express type as we will eventually expose the state machine via an API.&lt;/p&gt;

&lt;p&gt;The state machine workflow performs these main tasks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scrape (and sanitise) the HTML from a web page&lt;/li&gt;
&lt;li&gt;Generate the prompt by injecting the HTML into our prompt template&lt;/li&gt;
&lt;li&gt;Invoke Amazon Bedrock with our finalised prompt&lt;/li&gt;
&lt;li&gt;Handle the case where Claude was not able to find a recipe in the document&lt;/li&gt;
&lt;/ol&gt;


  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstatic.willdady.com%2Fimages%2F2024%2F05%2Frecipe-extractor-stepfunctions-graph_544x584.png" alt="Step Functions workflow diagram" width="544" height="584"&gt;Step Functions workflow diagram
  

&lt;h3&gt;
  
  
  Scraping the web page
&lt;/h3&gt;

&lt;p&gt;The first step of our state machine scrapes the contents of a web page containing our recipe. To do this I opted to create a simple Node.js based Lambda function using Typescript which takes a &lt;code&gt;url&lt;/code&gt; parameter and uses &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API" rel="noopener noreferrer"&gt;fetch&lt;/a&gt; to... ahem... &lt;em&gt;fetch&lt;/em&gt; the target web page.&lt;/p&gt;

&lt;p&gt;At this point I could simply return the raw HTML to advance the state machine but that would be extremely wasteful as there is a lot of redundant markup which serves little value, not to mention the longer the prompt the more it costs. We can do better!&lt;/p&gt;

&lt;p&gt;I opted to use &lt;a href="https://cheerio.js.org/" rel="noopener noreferrer"&gt;cheerio&lt;/a&gt;, a nifty HTML parsing library to clean-up the HTML before returning it from the function.&lt;br&gt;
Using cheerio I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extract the content of &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; tag&lt;/li&gt;
&lt;li&gt;Delete elements which have little semantic meaning to Claude such as &lt;code&gt;img&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;, &lt;code&gt;svg&lt;/code&gt; etc.&lt;/li&gt;
&lt;li&gt;Delete all attributes from elements&lt;/li&gt;
&lt;li&gt;Delete all &lt;code&gt;&amp;lt;!-- &amp;gt;&lt;/code&gt; comments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also do some final manipulation of the HTML string to remove excessive whitespace before returning the result.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Astute readers may note this is a far from ideal way of scraping a web page as it fails to consider dynamic content loaded after page load. My assumption is most recipe websites will deliver the complete recipe in the initial HTML sent from the server to aid SEO. That said, a headless Chromium + Puppeteer setup would likely be a better choice in a production environment.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Constructing the prompt
&lt;/h3&gt;

&lt;p&gt;For the second step of our state machine I wrote this short Lambda function. It takes the sanitised HTML output of the previous step and combines it with our prompt template. To do this I use &lt;a href="https://ejs.co/" rel="noopener noreferrer"&gt;ejs&lt;/a&gt; to replace the &lt;code&gt;&amp;lt;%- document %&amp;gt;&lt;/code&gt; placeholder in the prompt template with our sanitised HTML.&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ejs&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;ejs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PROMPT_TEMPLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`...`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Omitted for brevity&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Payload&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;input&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&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;input&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Payload&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ejs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROMPT_TEMPLATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;document&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;output&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;
  
  
  Invoking Amazon Bedrock
&lt;/h3&gt;

&lt;p&gt;Now we have our finalised prompt returned from the previous step we can invoke the model with it. What's nice is AWS Step Functions has a direct integration with Bedrock which means we can invoke it directly without first having to write another Lambda function. Here is what the &lt;code&gt;BedrockInvokeModel&lt;/code&gt; task looks like as defined in the CDK app.&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="nx"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FoundationModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromFoundationModelId&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FoundationModelIdentifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic.claude-3-haiku-20240307-v1:0&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;aiTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BedrockInvokeModel&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invoke Model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;model&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;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TaskInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;anthropic_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bedrock-2023-05-31&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&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;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="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;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JsonPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$.Payload.output&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="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;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="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="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;resultSelector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JsonPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringToJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JsonPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="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="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="nx"&gt;sfn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JsonPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$.Body.content[0].text&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;This takes the prompt I output from the previous step (&lt;code&gt;$.Payload.output&lt;/code&gt;) and invokes the Anthropic Claude 3 Haiku model as it's input.&lt;/p&gt;

&lt;p&gt;You might be wondering what that second 'assistant' message is. That is a message pre-fill which gives Claude a starting point on how to respond to the 'user' input. As instructed in our prompt template we always want Claude to respond with JSON. This combined with the prompt entered in the 'user' message helps guide Claude on how to respond. You can read more about how this works on Anthropic's website - &lt;a href="https://docs.anthropic.com/en/docs/control-output-format#prefilling-claudes-response" rel="noopener noreferrer"&gt;Control output format (JSON mode)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An interesting quirk of pre-filling the opening JSON brace is that it is not included in the message response we receive from Bedrock. You'll notice in the &lt;code&gt;resultSelector&lt;/code&gt; I am using &lt;code&gt;sfn.JsonPath.format&lt;/code&gt;, which is one of Step Function's &lt;a href="https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-intrinsic-functions.html" rel="noopener noreferrer"&gt;intrinsic functions&lt;/a&gt;, to prepend the missing opening brace. The resulting string is then converted to JSON with the &lt;code&gt;sfn.JsonPath.stringToJson&lt;/code&gt; function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exposing via API Gateway
&lt;/h3&gt;

&lt;p&gt;The last piece of the puzzle is to expose our state machine via &lt;a href="https://aws.amazon.com/api-gateway/" rel="noopener noreferrer"&gt;Amazon API Gateway&lt;/a&gt;. Within the same CDK app I instantiate a new &lt;code&gt;RestApi&lt;/code&gt; which proxies requests directly to my state machine.&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;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;apigateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RestApi&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;StepFunctionsRestApi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addProxy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;defaultIntegration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;apigateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;StepFunctionsIntegration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stateMachine&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;Once deployed the CDK CLI will output the URL of the newly created Rest API. Copy the URL so we can test it in our web browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it!
&lt;/h2&gt;

&lt;p&gt;At this point all that's left to do is test it out on some web pages. To do this all you need to do is navigate to a website containing a recipe and paste your API URL &lt;em&gt;in-front&lt;/em&gt; of the url in your browser's address bar.&lt;/p&gt;

&lt;p&gt;For example, my Rest API is available at &lt;code&gt;https://c7jdzx7r36.execute-api.ap-southeast-2.amazonaws.com/prod/&lt;/code&gt;. If I want to extract the recipe from &lt;code&gt;https://www.recipetineats.com/caramel-slice/&lt;/code&gt; I simply append the latter to the former e.g.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://c7jdzx7r36.execute-api.ap-southeast-2.amazonaws.com/prod/https://www.recipetineats.com/caramel-slice/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a few seconds, voila!... the recipe extracted from the page as JSON!&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;"output"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Caramel Slice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This is a Caramel Slice that works as promised – the creamy caramel sets perfectly and will never be runny, the chocolate won't crack when cutting it and the caramel won't ooze out. It's an easy recipe with no thermometer required."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ingredients"&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="s2"&gt;"1 cup flour, plain/all purpose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"1/2 cup brown sugar, loosely packed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"1/2 cup desiccated coconut (US: sweetened finely shredded coconut)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"125g / 4.5oz  unsalted butter, melted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"125g / 4.5oz unsalted butter, roughly chopped"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"1/2 cup (80g) brown sugar, loosely packed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"1 tsp vanilla extract (or essence)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"395g / 14oz sweetened condensed milk (1 can, 300ml)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"200g / 7oz dark or milk melting chocolate (US: semi-sweet chocolate chips)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"1 tbsp vegetable oil"&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;"steps"&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="s2"&gt;"Preheat oven to 180°C/350°F (fan 160°C)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Grease and line a 28x 18cm (lamington pan) / 7&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; x 11&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; rectangle pan with baking/parchment paper (Note 2). Have overhang for ease of removal."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Mix together Base ingredients and press into a pan (I use an egg flip)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bake for 15 minutes until the surface is golden. Cool in fridge if you have time (Note 3)."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Lower oven to oven to 160°C/320°F (fan 140°C)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Place butter, sugar and vanilla in a saucepan over medium low heat. When the butter is melted, whisk to combine with sugar, then just leave it until it comes to a simmer."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"When bubbles appear, add condensed milk. Whisk constantly for 5 minutes (Note 4), until you start getting some big slow bubbles on the base."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Once bubbles start appearing, whisk for 1 minute, then pour onto Base. Tilt pan to spread evenly."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bake for 12 minutes. Don't worry if you get splotchy brown bits (this happens with ovens that don't distribute heat evenly)."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Cool on counter for 20 minutes then refrigerate 30 minutes - bottom of pan should be warm but surface cool (not cold) to touch. (Note 5)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Place chocolate and oil in a microwave proof bowl. Microwave in 30 second bursts, stirring in between, until chocolate is fully melted (takes me 4 x 30 sec)."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Pour over caramel, spread with spatula. Then gently shake pan to make the surface completely flat."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Refrigerate 1 hour or until set. Remove from fridge and leave out for 5 minutes to take chill out of chocolate slightly. Then cut into bars or squares to serve!"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is an example of what is output when you provide a URL to something which doesn't contain a recipe.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://c7jdzx7r36.execute-api.ap-southeast-2.amazonaws.com/prod/https://news.ycombinator.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No recipe found in document"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cause"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The document appears to be a Hacker News article listing and does not contain any recipes."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;I had a lot of fun building this and hopefully this post highlights how combining serverless technologies with generative AI can be used for novel outcomes. This is very much a toy/experimental project and is not intended for serious production use. To make this safer for use in production a number of additional features would need to be added such as improved error handing, authentication at the API layer and caching to name a few.&lt;/p&gt;

&lt;p&gt;If you'd like to try it out yourself you can find the complete CDK app here &lt;a href="https://github.com/willdady/recipe-extractor-cdk" rel="noopener noreferrer"&gt;https://github.com/willdady/recipe-extractor-cdk&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Photo by &lt;a href="https://unsplash.com/@videmusart?utm_content=creditCopyText&amp;amp;utm_medium=referral&amp;amp;utm_source=unsplash" rel="noopener noreferrer"&gt;Syd Wachs&lt;/a&gt; on &lt;a href="https://unsplash.com/photos/onions-and-potato-on-table-epqNIYI6S7E?utm_content=creditCopyText&amp;amp;utm_medium=referral&amp;amp;utm_source=unsplash" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>generativeai</category>
      <category>systemdesign</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Photo mosaics with Rust</title>
      <dc:creator>Will Dady</dc:creator>
      <pubDate>Sat, 10 Aug 2019 23:24:36 +0000</pubDate>
      <link>https://dev.to/willdady/photo-mosaics-with-rust-546</link>
      <guid>https://dev.to/willdady/photo-mosaics-with-rust-546</guid>
      <description>&lt;p&gt;When I was a kid I remember seeing a poster of Yoda from Star Wars which was made up from various screenshots from the movie. I remember being facinated how several tiny images could be arranged to create a larger image and that how the colour of each small image contributed to the larger picture overall.&lt;/p&gt;

&lt;p&gt;The idea of arranging small coloured tiles to create images has been around since 300 BC and artwork created in such a way is called a &lt;strong&gt;Mosaic&lt;/strong&gt; which &lt;a href="https://en.wikipedia.org/wiki/Mosaic" rel="noopener noreferrer"&gt;Wikipedia&lt;/a&gt; describes as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;a piece of art or image made from the assembling of small pieces of colored glass, stone, or other materials.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;While the technique of arranging coloured tiles to form an image has been around for hundreds of years, photo mosaics are a relatively new take on the art style.&lt;/p&gt;

&lt;h2&gt;
  
  
  Photo mosaics
&lt;/h2&gt;

&lt;p&gt;The first mainstream use of a photo mosaic I recall was on one of the official posters for the 1998 film staring Jim Carrey, The Truman Show.&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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F08%2Ftruman_poster.jpg" 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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F08%2Ftruman_poster.jpg" alt="A poster for The Truman Show" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
A photo mosaic poster for The Truman Show (1998)



&lt;p&gt;You can see in the following close-up of the poster, specifically Jim Carrey's right eye, the image is made up of various scenes from the film. Interestingly each tile is tinted rather than solely relying on the actualy colour in the frame. It's a forgivable workaround considering there probably isn't that many fleshy-beige coloured scenes in the movie to sample from.&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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F08%2Ftruman_poster_eye.jpg" 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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F08%2Ftruman_poster_eye.jpg" alt="A close-up of the poster for The Truman Show (1998)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
A close-up of the poster for The Truman Show (1998)



&lt;h2&gt;
  
  
  Writing a photo-mosaic generator
&lt;/h2&gt;

&lt;p&gt;As I've been learning the &lt;a href="https://www.rust-lang.org/" rel="noopener noreferrer"&gt;Rust programming language&lt;/a&gt; lately I thought creating a photo-mosaic generator could be a fun project. Rust is a relatively new systems programming language akin to C/C++ in terms of performance. What makes Rust unique is the way it manages memory compared to other low-level languages.&lt;/p&gt;

&lt;p&gt;I knew a generator like this would require a large set of tile images to sample from. The original plan was for it to only create mosaics from emoji which is how it got it's name &lt;strong&gt;emosaic&lt;/strong&gt; &lt;em&gt;(emoji + mosaic = emosaic)&lt;/em&gt; but as I progressed it made more sense to keep in generic and let the user provide their own pool of tile images.&lt;/p&gt;

&lt;p&gt;Similar to my previous (and first) Rust project &lt;a href="https://github.com/willdady/swirlr" rel="noopener noreferrer"&gt;swirlr&lt;/a&gt;, I used dependencies from &lt;a href="https://crates.io" rel="noopener noreferrer"&gt;crates.io&lt;/a&gt;, Rust's equivalent to NPM for Node.js. The main libraries I used are &lt;a href="https://crates.io/crates/image" rel="noopener noreferrer"&gt;image&lt;/a&gt;, for loading an manipulating images and sampling pixels, and &lt;a href="https://crates.io/crates/clap" rel="noopener noreferrer"&gt;clap&lt;/a&gt; a command line argument parser.&lt;/p&gt;

&lt;p&gt;Emosaic works by taking a directory of images used as tiles and a source image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;emosaic /path/to/tiles/ source.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The output file will be saved to the current directory as &lt;code&gt;output.png&lt;/code&gt; though a custom output path can be provided via the &lt;code&gt;-o&lt;/code&gt; option. Output is always in PNG format.&lt;/p&gt;

&lt;p&gt;The app works by first reading all images in the tiles directory. Each image's pixels are looped over to find the average colour of each image. I found it necessary to exclude any tiles where more than 50% of it's pixels are completely transparent as these didn't look good in the final output. The image's path and average colour is then added to a &lt;code&gt;Tile&lt;/code&gt; struct. Each &lt;code&gt;Tile&lt;/code&gt; struct was then added to a &lt;code&gt;TileSet&lt;/code&gt; struct which represented all available tiles to choose from in the next step.&lt;/p&gt;

&lt;p&gt;Next the source image is loaded and for each pixel in the source image we call the &lt;code&gt;TileSet&lt;/code&gt;'s &lt;code&gt;closest_tile(&amp;amp;self, rgba: Rgba&amp;lt;u8&amp;gt;) -&amp;gt; Tile&lt;/code&gt; method. This method works by naively looping over each tile and comparing the distance between colours. We say &lt;em&gt;"distance"&lt;/em&gt; as we calculate how close one colour is to another by treating each as a point in 3D space RGB -&amp;gt; XYZ (alpha is ignored). The resulting Tile is then added to a Map keyed by the pixel colour that way subsequent lookups can simply check if the Tile for a given colour has already been found, this is a particularly useful optimization as lossy formats like JPEG will repeat colours often.&lt;/p&gt;

&lt;p&gt;Once the tile is found it's copied to the same row/column coordinates in the output image factoring in the desired tile size. The tile size can be configured as the optional command line option &lt;code&gt;-t&lt;/code&gt; which defaults to 16. For example a 100x100 source image yields an output image of 1600x1600 (100 x 16 = 1600) so it's important the source image is small.&lt;/p&gt;
&lt;h2&gt;
  
  
  Further improvements
&lt;/h2&gt;

&lt;p&gt;Im pretty happy with how this turned out and it's super fast thanks to the low-level nature of Rust. For example, the Marilyn Monroe image above is generated from a directory of 2625 tiles, each with dimensions of 64x64 and a source image with with dimensions of 100x100. The command completes in about &lt;strong&gt;2.9&lt;/strong&gt; seconds on a 2017 Macbook Pro.&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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F08%2Fwarhol.jpg" 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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F08%2Fwarhol.jpg" alt="100x100 source image" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
100x100 source image



&lt;p&gt;Some further optimisations could be made such as caching the TileSet to disk that way the program doesn't need to read potentially thousands of tile images every time the app is run. Also at the time of writing it does &lt;em&gt;not&lt;/em&gt; provided an option to 'tint' the output image like the Truman Show example above.&lt;/p&gt;

&lt;p&gt;Overall I'm really liking Rust. It has a very steep learning curve but it's rewarding once the borrow checker stops complaining. It's nice to feel confident that if the app compiles it will run safely. I find small projects like this are a great way to get familiar with a new programming language.&lt;/p&gt;

&lt;p&gt;Feel free to &lt;a href="https://github.com/willdady/emosaic" rel="noopener noreferrer"&gt;checkout the complete source code for emosaic on github&lt;/a&gt;.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/willdady" rel="noopener noreferrer"&gt;
        willdady
      &lt;/a&gt; / &lt;a href="https://github.com/willdady/emosaic" rel="noopener noreferrer"&gt;
        emosaic
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Mosaic image generator written in Rust!
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;emosaic&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Mosaic generator written in &lt;a href="https://www.rust-lang.org/" rel="nofollow noopener noreferrer"&gt;Rust!&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/willdady/emosaic/example/warhol.png?raw=true"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fwilldady%2Femosaic%2FHEAD%2Fexample%2Fwarhol.png%3Fraw%3Dtrue" alt=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Building&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;To build &lt;a href="https://www.rust-lang.org/tools/install" rel="nofollow noopener noreferrer"&gt;make sure you have rust installed&lt;/a&gt;.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;cargo build --release
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Once compiled, the binary can be found at &lt;code&gt;target/release/emosaic&lt;/code&gt; in the repository root.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Usage&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;The command expects a path to a directory containing square 'tile' images and a source image.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;emosaic /path/to/tile/images/ source.png
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Modes&lt;/h3&gt;

&lt;/div&gt;

&lt;p&gt;The strategy used to generate the mosaic is controlled by the &lt;code&gt;-m, --mode&lt;/code&gt; option.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h4 class="heading-element"&gt;1to1 (Default)&lt;/h4&gt;

&lt;/div&gt;

&lt;p&gt;For each pixel in the source image a tile with the nearest matching average color will be emitted.&lt;/p&gt;

&lt;p&gt;Assuming a source image with dimensions 100x100 and default tile size of 16 the output image will be 1600x1600.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h4 class="heading-element"&gt;4to1&lt;/h4&gt;

&lt;/div&gt;

&lt;p&gt;For every 2x2 pixels one tile will be emitted. Tiles are divided into 2x2 segments and the average colour of each segment is stored. The tile with the nearest average color in &lt;em&gt;each&lt;/em&gt; segment to the target pixels will be chosen. This mode…&lt;/p&gt;&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/willdady/emosaic" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


</description>
      <category>rust</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Creating a remote BitTorrent box with Terraform, Transmission and Docker on AWS</title>
      <dc:creator>Will Dady</dc:creator>
      <pubDate>Sun, 14 Jul 2019 12:47:12 +0000</pubDate>
      <link>https://dev.to/willdady/creating-a-remote-bittorrent-box-with-terraform-transmission-and-docker-on-aws-2m40</link>
      <guid>https://dev.to/willdady/creating-a-remote-bittorrent-box-with-terraform-transmission-and-docker-on-aws-2m40</guid>
      <description>&lt;p&gt;My head has very much been in the DevOps space lately as I've been more and more hands on with AWS. I got thinking about an idea I had some time ago... What would be involved in running an ephemeral BitTorrent client on AWS with downloads automatically saved to S3?&lt;/p&gt;

&lt;p&gt;My ultimate goal was to be able to spin up and tear down a remote BitTorrent client all from the command line. I knew I wanted to run &lt;a href="https://transmissionbt.com/" rel="noopener noreferrer"&gt;Transmission&lt;/a&gt; as I had used it in the past as it has a web interface for adding and removing torrents and monitoring downloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  VPN
&lt;/h2&gt;

&lt;p&gt;As I'm hosting this in the cloud I really wanted the BitTorrent traffic to route via a VPN rather than directly to the host instance. When I first started experimenting with this idea I begain by spinning up a t2.micro instance via the AWS console, connecting via SSH and installing OpenVPN directly on a host. I quickly discovered the hard way than starting OpenVPN while connected to the host over SSH wouldn't work as I guess by default it takes over the host's network traffic, immediately dropping my SSH connection and bricking the server until it's rebooted via the AWS console.&lt;/p&gt;

&lt;p&gt;This lead me to Docker. Having OpenVPN containerised felt like a more sensible solution as it would allow me to SSH into the host instance to tinker with the setup. I figured someone must have already created an OpenVPN docker image and after some googling I found that github user &lt;a href="https://github.com/haugene" rel="noopener noreferrer"&gt;haugene&lt;/a&gt; had created an aptly named image, &lt;a href="https://github.com/haugene/docker-transmission-openvpn" rel="noopener noreferrer"&gt;docker-transmission-openvpn&lt;/a&gt;, which bundles both OpenVPN and Transmission together. Bonus!&lt;/p&gt;

&lt;h2&gt;
  
  
  Syncing completed downloads to S3
&lt;/h2&gt;

&lt;p&gt;Once downloads complete Transmission copies files to the &lt;code&gt;/data/completed&lt;/code&gt; directory. I needed to figure out a way to automatically copy completed downloads to S3. For this I decided to write my own utility in Go which would simply watch the contents of a directory for new files and upload them to an S3 bucket. This utility is called &lt;a href="https://github.com/willdady/go-watch-s3" rel="noopener noreferrer"&gt;go-watch-s3&lt;/a&gt; and I have a dedicated blog post about this &lt;a href="https://willdady.com/uploading-new-files-to-s3-with-golang" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Both the Transmission container and the container running go-watch-s3 share a bind-mounted directory so when Transmission wrote files to it's &lt;code&gt;/data/completed&lt;/code&gt; directory go-watch-s3 would see the new files and upload them. Pretty neat!&lt;/p&gt;

&lt;h2&gt;
  
  
  Provisioning the instance with Terraform
&lt;/h2&gt;

&lt;p&gt;The provisioning is handled by a &lt;a href="https://www.terraform.io/" rel="noopener noreferrer"&gt;Terraform&lt;/a&gt; configuration which sets up our infrastructure. It creates the EC2 instance, a security group so we can access the Transmission UI, and the appropriate IAM role, policy and instance profile so the instance is able to write files to our existing S3 bucket.&lt;/p&gt;

&lt;p&gt;Installing software on the instance is handled as part of a user data script which runs when the instance first launches and is defined as part of the &lt;strong&gt;aws_instance&lt;/strong&gt; resource type in Terraform. This script is actually relatively simple as it just installs Docker, pulls the above mentioned images and runs them. I had considered using &lt;a href="https://docs.docker.com/compose/" rel="noopener noreferrer"&gt;Docker Compose&lt;/a&gt; as it's usually a nicer experience when orchestrating multiple containers but went ahead with vanilla docker run commands instead.&lt;/p&gt;

&lt;p&gt;Finally, all that's left to do is to apply the configuration with &lt;code&gt;terraform apply&lt;/code&gt; to create our resources on AWS. Once the configuration is applied the url of the Transmission web UI will be output however you will need to wait a few minutes for the startup script to do it's thing. Once the UI is available torrents can be added with completed downloads auto-magically copied to S3!&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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F07%2Ftransmission-web-ui.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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F07%2Ftransmission-web-ui.png" alt="Transmission Web UI" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once you're finished, destroying the infrastructure you just created can be torn down with &lt;code&gt;terraform destroy&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stating the obvious
&lt;/h2&gt;

&lt;p&gt;In summary I feel like this was a fun 'toy' project though I don't personally feel the need to improve it further. The itch has been scratched if you will. To see the complete Terraform configuration check out &lt;a href="https://github.com/willdady/terraform-transmission-aws" rel="noopener noreferrer"&gt;terraform-transmission-aws&lt;/a&gt; on github.&lt;/p&gt;

&lt;p&gt;While there is nothing illegal about BitTorrent from a pure technology standpoint, pirating content may well fast-track you to a reprimand from AWS or worse your account closed.&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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F07%2Faws-rage.gif" 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%2Fs3-ap-southeast-2.amazonaws.com%2Fmedia.willdady.com%2F2019%2F07%2Faws-rage.gif" alt="AWS shutting you down" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>terraform</category>
      <category>aws</category>
      <category>bittorrent</category>
    </item>
  </channel>
</rss>
