
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-us">
  <title>amberwilliams.io</title>
  <subtitle>Amber Williams’ blog</subtitle>
  <link href="https://amberwilliams.io"/>
  <link href="https://amberwilliams.io/api/atom.xml" rel="self"/>
  <updated>2025-05-24T20:29:05.802Z</updated>
  <author>
    <name>Amber Williams</name>
  </author>
  <id>https://amberwilliams.io</id>
  
      <entry>
        <title>Beyond bookmarks</title>
        <link href="https://amberwilliams.io/blogs/bookmarks"/>
        <id>https://amberwilliams.io/blogs/bookmarks</id>
        <published>2025-05-24T20:29:05.802Z</published>
        <updated>2025-09-24T18:33:07.961Z</updated>
        <content type="html"><![CDATA[<p>I often find myself reading noteworthy articles, akin to how Simon Willison&#39;s using <a href="https://til.simonwillison.net/llms/openai-embeddings-related-content">text embeddings</a> to relate his blog posts to one another and Justine Tunney&#39;s snappy <a href="https://justine.lol/oneliners/">Bash commands</a> leveraging LLMs. These insightful articles either inspired me, taught me something new, or provoked a new frame of mind. I try to keep a tangible mental record of these sorts of articles. After all, brains do their own housekeeping, quietly deleting outdated information.</p>
<h2>Efficiently inefficient</h2>
<p>For years I have been manually taking notes as I read through articles. If I found the article worth remembering, I would reference the key parts in a note then file the note under a relevant topic. For instance, notes taken from an article on &quot;Pytest best practices&quot; would go under <code>Python</code>. Keeping notes organized like this was exhausting work. However, it was critical for searchability, particularly when I needed to recall the ideas again. </p>
<p>Inevitably, fractional notes started to form across my work computer, phone and laptop. It didn&#39;t help matters that I would infrequently glue the fragmented notes together, so most of the time, I would abandon orphaned notes altogether. I soon realized reading articles leisurely, without saving notes, was more productive as I could read more content in the same amount of time it took to write notes. The problem with that is that relying solely on long term memory isn&#39;t feasible.</p>
<p>There have been numerous articles I&#39;ve pulled my hair out while scouring my browser&#39;s history attempting to locate an invaluable article. A good example of this was when I wanted a junior developer to have the same framing when writing tests as I had. I once read a series of articles by Kent C. Dodds on the fundamentals of React Testing Library which changed the way I write tests to this day. While Kent still has useful posts around the <a href="https://kentcdodds.com/blog?q=testing+react">topic</a>, I could no longer find the ones I had once revered. I suspect this is because the information has been moved into a testing <a href="https://www.testingjavascript.com/">course</a> behind a paywall. This meant I couldn&#39;t rely on my browsing history.</p>
<h2>Dipping into extensions</h2>
<p>A sense of relief and excitement washed over me as I realized I could finally create a frictionless way to collect highlights across the web. This was possible because I had accrued an ecosystem of projects from a <a href="https://amberwilliams.io/blogs/component-library-long-term-payoff">component system</a> to a <a href="https://amberwilliams.io/blogs/building-my-own-pkms">note taking system</a>. These projects unlocked the ability to rapidly create my own browser extension-based bookmark manager. This bookmark manager could send slices of the web to my notes system via an API.</p>
<p>This tool can be found as Queso<a href="small%3E%20I%20have%20been%20naming%20my%20projects%20after%20different%20cheeses%20recently.%20This%20Chrome%20extension,%20being%20built%20whilst%20in%20the%20middle%20of%20a%20rainy%20week%20in%20Mexico,%20was%20dubbed%20%5BQueso%5D(https://chromewebstore.google.com/detail/queso-affineur-apps/cpojjpkakkkhmdopjephddfnnfalfdbd).%20%3C/small">^1</a> — it&#39;s an <a href="https://github.com/Amber-Williams/queso-browser-extension">open-source</a> Chrome <a href="https://chromewebstore.google.com/detail/queso-affineur-apps/cpojjpkakkkhmdopjephddfnnfalfdbd">extension</a>. Nowadays, when I&#39;m browsing the web, I&#39;ll bookmark the articles I want to read later. </p>
<h2>Beyond bookmarks</h2>
<p>To help burn down articles I want to read, I have a job that generates a newsletter with articles I haven&#39;t read yet (totaling to 30 minutes of read time) and emails the newsletter to me daily. Each bookmark in the newsletter has a link to the article and a link to the bookmark in my notes system.</p>
<div style="max-width:100%;margin:0 auto;">
<img class="rounded w-100 my-3 border"  alt="My newsletter of articles to read sent to my email that Queso bookmarked for reading later." src="https://notes.holeytriangle.com/assets/23438acc-2b17-47eb-99ad-ebda43ea46c3">
 </div>
 
<p>Queso is also built in a way that I can simultaneously read through an article and jot my musings in Chrome. After completing an article I can also mark that I have read it using Queso. The biggest time-saver came from automatically linking the web article&#39;s metadata. Queso pre-populates the article&#39;s URL, title, author, and generates estimated read time for every bookmark. As a start, Queso was a reading-based to-do list for unread content and a digital breadcrumb trail for the rest.</p>
<div style="max-width:80%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Displays Queso's sidebar feature to take notes while reading through an article" src="https://notes.holeytriangle.com/assets/dce83caa-1fba-41ad-bb85-746b19766657">
 </div>


<p>As I was dogfooding the app for a few months, I found that often I wanted to save quotes around the place. So I added a feature where on any webpage I highlight a portion of text, right click and Voilà!</p>
<div style="max-width:80%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Displays Queso's highlighted text to quote feature" src="https://notes.holeytriangle.com/assets/a9e33da0-53fa-43d2-a035-58951128f845">
 </div>

<p>The quote is now in the notes field and formatted as a markdown quote. Quotes work similarly to bookmarks, pre-populating the reference source&#39;s metadata with cited references to the URL and author. This feature will be particularly useful to embed quotes in my own blog posts!</p>
<p>Here&#39;s how that quote looks in my notes system:</p>
<div style="max-width:80%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Shows how the added quote entry looks in my notes system after adding it through Queso." src="https://notes.holeytriangle.com/assets/72d77a7c-a50a-4784-bc6a-dffcdc75c3e6">
 </div>

<h2>Above and beyond</h2>
<p>I had been using bookmarks and quotes for a couple of months when my digital hoarding kicked into overdrive. An itch to save the things I learned throughout the day needed to be scratched. Some might think this is a time sink. But as a software engineer, in the midst of an AI boom, I learn a plethora of new things regularly. Besides, bookmarking the things a one learns throughout time isn&#39;t novel. In fact, this is commonly known as a Today I Learned (TIL) list. Some developers have chosen to keep their own <a href="https://github.com/jbranchaud/til">TIL as repositories</a> on GitHub. My TIL would function similarly, but live in the same place as my bookmarks.</p>
<p>Adding support for TIL was straightforward, as it functions similarly to quotes and bookmarks. It was helpful that my notes system stores notes in Markdown, so when there are parts of code I find inspirational I can save code inside code blocks. Here&#39;s a TIL entry from when I learned about <code>JSON-LD</code> . Its tagged with <code>SEO</code> so I can quickly reference it next time I&#39;m setting up a new marketing site.</p>
<div style="max-width:80%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Shows how a TIL looks with code blocks in my notes system." src="https://notes.holeytriangle.com/assets/83718288-0024-46f1-8f0e-a89db98b6894">
 </div>


<h2>An internet swiss knife</h2>
<p>I&#39;ve recently added support for Queso to take a snapshot of an article&#39;s written content when a bookmark is added. The snapshot takes the article&#39;s HTML content, pops it into Markdown format, and is uploaded with each bookmark. Thus preventing articles lost to <a href="https://en.m.wikipedia.org/wiki/Link_rot">link rot</a>. So preserves not only my notes on the subject, but also the cited source&#39;s content!</p>
<p>Here&#39;s a demonstration of the snapshot feature in action using an article I wrote. The article contains relatively complex page elements for a blog, such as code blocks and tables. In one click with Queso I have now cached the article content and in a consecutive click I have uploaded it to my PKMS.</p>
<div style="max-width:90%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Demos content snapshots in Queso" src="https://notes.holeytriangle.com/assets/24c00c08-0c49-4fe1-98af-510564213603">
 </div>


<p>Preserving the article&#39;s content doesn&#39;t only prevent against data loss. It unlocks a heap of benefits such as the ability to read articles offline. Another benefit that may not be obvious is auto-linking related content using text-embeddings. As these links accrue, a network of inter-connected content highways start to form. This would act similar to manual cross-linking notes using <a href="https://help.obsidian.md/plugins/backlinks">Obsidian&#39;s Backlinks</a>.</p>
<div style="max-width:80%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Shows Obsidian Backlinks as a network graph." src="https://notes.holeytriangle.com/assets/17c456b4-3c2c-41f9-bb90-e8f18787da22">
 </div>

<p>An important distinction from links in Obsidian is that regardless of when the note was made, it can easily be resurfaced when recent content intersects with old content. For example, 10 years ago you wrote about your <a href="https://www.atkins.com/how-it-works/blog/the-carnivore-diet-and-atkins">Atkins diet</a>, today you create a note about your new carnivore diet. The decade old Atkins diet note pops-up in relevancy and you revisit. This automated discoverability helps reinforce and compound knowledge over time.</p>
<h2>Closing thoughts</h2>
<p>There are plenty of options when it comes to retaining knowledge online. There are already several bookmark mangers that work for others. For me, Queso has drastically improved the way I retain web-based content, so I plan to keep using it for the foreseeable future. </p>
<p>For those interested in using Queso themselves, it&#39;s available for free on the <a href="https://chromewebstore.google.com/detail/queso-affineur-apps/cpojjpkakkkhmdopjephddfnnfalfdbd">Chrome web store</a>. There are configurable settings so that it can be easily hooked up to upload bookmarks through an API. With services like Firebase, Supabase, or a DIY bookmark system can be set up quickly to start using Queso. Queso is also <a href="http://github.com/amber-Williams/queso-browser-extension/">open-source</a> for anyone interest in contributing to the project or repurposing the code.</p>
<div style="max-width:80%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Displays Queso's settings page" src="https://notes.holeytriangle.com/assets/907e4a67-9f82-4540-86f1-08f6ce31fb2f">
 </div>


<br/>
<br/>
<br/>

<hr>
]]></content>
      </entry>
      <entry>
        <title>Ditching Obsidian and building my own</title>
        <link href="https://amberwilliams.io/blogs/building-my-own-pkms"/>
        <id>https://amberwilliams.io/blogs/building-my-own-pkms</id>
        <published>2025-05-05T14:26:24.139Z</published>
        <updated>2025-05-28T21:41:45.693Z</updated>
        <content type="html"><![CDATA[<blockquote>
<p>&quot;You can’t really know where you are going until you know where you have been.&quot; - Maya Angelou. </p>
</blockquote>
<p>The age-old mission to capture and make sense of our knowledge and experiences echoes through history, from Thomas Jefferson&#39;s Commonplace book detailing his ideas on government to Marcus Aurelius&#39;s Meditations saturated with quotations and personal reflections.</p>
<p>However, this pursuit to preserve knowledge isn&#39;t without its own set of fears. Are our deepest thoughts private? Are we spending a disproportionate time customizing our notes system than benefiting from it? Will our preferred notes system last the sands of time? </p>
<p>These were the questions that motivated me to step outside conventional offerings and build my own solution. I&#39;m sharing my story here not to prescribe, but to demonstrate it&#39;s okay to color outside the lines. Perhaps my journey to create a simple, secure, and lasting &#39;note vault&#39; will spark ideas for how you can better cultivate your own knowledge garden.</p>
<h2>Conventional knowledge gardens</h2>
<p>A personal knowledge management systems (PKMS), also known as &quot;second brain&quot; is are central repositories for actively collecting one&#39;s meaningful insights, ideas and inspirations encountered throughout life. The benefit of decades of notes accruing and reinforcing knowledge is immense. That is why critically analyzing what you need your PKMS to achieve is crucial. </p>
<p>The most commonly used PKMS or note-taking apps today are Notion, Obsidian, Evernote and Logseq. The problem is that PKMS come and go. Could you see yourself using your note-taking app you use today in 30 years? Probably not. Do you ever have concerns around the privacy of your notes? Are you spending more time setting up your notes system rather than managing your notes? What does an effective and timeless PKMS even look like?</p>
<h2>Pulling at a thread</h2>
<p>My PKMS journey started with Obsidian. For those unfamiliar with Obsidian, it&#39;s a digital notebook that lives only on your computer. Its special feature is letting you internally link related notes together similar to Wikipedia. Beyond linking, its true power comes from the huge range of plugins. Out of all the great community plugins, Dataview is my personal favorite. <a href="https://obsidian.rocks/dataview-in-obsidian-a-beginners-guide/">Dataview</a> is a powerful scripting tool which pulls data from markdown notes to generate tables, graphs, and other insights.</p>
<p>Obsidian was a great tool for me personally for a long time. But I felt frustrated when I wanted to access my notes on my phone while on-the-go and saw that I had to pay for this feature. Obsidian charges $8 a month to access the same notes across multiple devices. While not a huge amount for such a useful app, it adds up to an eye-watering amount - almost $1,000 if I planned to use Obsidian for a decade. I was surprised at this fee because I thought Obsidian was open-source. I later realized that the Obsidian community plugins are open-source, but Obsidian itself is, in fact, not. These were the same plugins I was spending an inordinate abount of time <a href="https://github.com/s-blu/obsidian_dataview_example_vault/blob/b6740b4d8a7e0b408669563e1b3c4028df36e207/20%20Dataview%20Queries/Show%20a%20Goals%20Overview%20with%20progress%20bars%20for%20included%20projects%20and%20overall%20progress.md">personalizing Obsidian with</a>, rather than being productive.</p>
<p>I started to have concerns about the longevity of the plugins and app itself. Some of you may remember when Evernote aggressively limited free users to <a href="https://henry-chong.com/blog/the-downfall-of-evernote/">50 notes</a>, many users migrated their notes elsewhere. I was one of those users.</p>
<p>After some mental gymnastics weighing if I should continue with Obsidian, I found solace when asking myself <em>&quot;Can I see myself using this in 20 years?&quot;</em>. I couldn&#39;t. The thought of cyclically migrating notes from one PKMS to another every 5 years, as I had done from Evernote to Notion to Obsidian, made me feel tired. So I grabbed some coffee and began my search for the digital equivalent of the <a href="https://en.wikipedia.org/wiki/Svalbard_Global_Seed_Vault">Svalbard Global Seed Vault</a>, but for my notes.</p>
<h2>My perfect note vault</h2>
<p>Key requirements were that my new PKMS needed to be easy to use, offer a plugin-like experience and be secure. Privacy and security were paramount. I didn&#39;t want my innermost thoughts being exposed to a <a href="https://en.wikipedia.org/wiki/23andMe_data_leak">23andMe data leak</a> scenario. Data security aside, I have a hard time trusting for-profit companies will keep their hands out of the cookie jar. I can imagine there&#39;s a killing to be made from targeted advertisements online using your notes or for them to be used to train AI. I wanted to know with certainty that I control my data. Entrusting a company to store my notes for free, couldn&#39;t give me that assurance.</p>
<p>Being a full-stack software engineer, the answer was obvious. I should step outside the commercial-PKM ecosystem entirely - I should build my own.</p>
<p>But if it&#39;s so obvious, why aren&#39;t other developers rolling out their own PKMS? Perhaps I&#39;m the first to discover this or perhaps developers aren&#39;t writing about their custom PKMS. My guess is that commercial note-taking apps have larger, more vocal communities that drown out the murmurings of other DIY solutions.</p>
<p>It was here I decided to pull the trigger to build and host my own PKMS. It felt pertinent to write about it so others feel confident to do so too. Spoiler - it sounds like a daunting feat, but in reality it was so comically easy my only regret is that I hadn&#39;t done it years ago.</p>
<h2>The note vault</h2>
<p>Here&#39;s how my PKMS looks as of writing. I can create a note, update it in markdown format and then preview what the note looks like in rendered markdown.</p>
<div style="max-width:80%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="gif using the PKMS's markdown editor" src="https://notes.holeytriangle.com/assets/0f1cfdfb-ed3d-450e-8701-8ef7a9f2d7a5">
 </div>

<p>Finally, I can access my notes from my phone too, right where I left off. No monthly fees for this feature either.</p>
<div  style="max-width:50%;margin:0 auto;"><img  class="rounded w-100 my-3"  alt="Using the PKMS from my phone with a thumbs up sign" src="https://notes.holeytriangle.com/assets/e12f4587-b8a5-4739-9b16-7e925037ac9b"></div>

<p>Since my PKMS is hosted online to manage notes across devices, I have multiple layers of security to ensure my notes are kept private.</p>
<div  style="max-width:50%;margin:0 auto;"><img  class="rounded w-100 my-3"  alt="the PKMS's login screen with an empty form" src="https://notes.holeytriangle.com/assets/c42ab6b2-4eae-4956-b886-50d70aacba70"></div>

<p>And if I ever want to move to greener pastures, all my notes are stored in Markdown so I can export them with two command lines to my terminal. Here&#39;s an under-the-hood view of how my notes are stored in the database - just text! <a href="small%3E%20Completely%20ignoring%20or%20not%20caring%20about%20end-to-end%20encryption%20would%20be%20naive%20of%20me.%20It's%20an%20important%20layer%20of%20security%20that%20ensures%20my%20notes%20stay%20private%20in%20the%20event%20a%20hacker%20gets%20ahold%20of%20my%20database.%20Until%20I%20get%20the%20time%20to%20add%20encryption%20to%20my%20PKMS,%20I%20strictly%20limit%20sensitive%20data%20such%20as%20password%20storage%20to%20a%20minimum.%3C/small">^1</a></p>
<div  style="max-width:50%;margin:0 auto;"><img  class="rounded w-100 my-3"  alt="shows the markdown output from a database query" src="https://notes.holeytriangle.com/assets/2847aa38-5356-4e1d-80d9-feb5e738eee2"></div>

<h2>Benefits of a note vault</h2>
<p>I&#39;ve found that consistently collecting and reviewing valuable information promotes a deeper engagement with ideas. As a result, I&#39;ve noticed my memory improving, and I started seeing surprising links between seemingly unrelated topics. Plus, it&#39;s created this incredible record of my learning and personal growth.</p>
<p>I found Ryan Holiday&#39;s take on the <a href="https://ryanholiday.net/how-and-why-to-keep-a-commonplace-book/">Commonplace Book</a> particularly insightful for efficiently using a PKM. While Ryan advocates for analog notes, the same principles can be applied to digital notes. While physical writing has its merits, digital notes provide superior search capabilities and flexible organization. Ryan himself states how impracticability of a physical PKMS - &quot;Because mine is a physical box with literally thousands of cards, I don’t carry the whole thing with me&quot;. </p>
<div  style="max-width:70%;margin:0 auto;"><img  class="rounded w-100 my-3"  alt="Illustration featuring a system for filing notes" src="https://notes.holeytriangle.com/assets/85ba3989-f61c-4f4f-8764-b1f8c267cbaa"></div>
<small> <center>Image sourced from Houghton Library, Public domain, via Wikimedia Commons</center></small>

<br/>

<p>And that&#39;s the beauty of a digital PKMS – it all fits on my phone!</p>
<p>Additionally, now with AI code generation creating custom PKMS plugins are more accessible than ever before. No longer do we need to install a random developer&#39;s plugin that sends our private thoughts who knows where. We can vibe-code our own or use open-source tooling. I used to use an Obsidian plugin to pull uncompleted tasks from todo checklists that were scattered around various notes, but after 10 minutes of prompting Anthropic&#39;s Claude, I had my own private equivalent tool.</p>
<h2>A look inside</h2>
<p>Using essentially a wrapper around a database made creating my PKMS easy. This wrapper does the heavy lifting, keeping things simple and secure. I went with a platform called <a href="https://directus.io/">Directus</a>, which can be used as a PKMS amongst other things such as a CMS. Directus has authentication and security layers already included, so I was able to start using it in less than a day.</p>
<p>For readers who have worked with SQL databases or docker containers - I wrote a <a href="https://amberwilliams.io/blogs/the-last-note-system">step-by-step guide on building my own PKMS with Directus</a>.</p>
<h2>A final note</h2>
<p>I’ve always believed that, much like a garden, how we manage our own knowledge system needs cultivation and continuity to flourish. My own knowledge garden was cultivated from a deep dive into the subject of note systems, driven by a search for a solution to those nagging frustrations I think many of us feel with commercialized systems.</p>
<p>A knowledge garden isn&#39;t always straightforward, and I&#39;ll be the first to admit I’ve been guilty of spending considerably more time fiddling with a system, rather than actually using it. It&#39;s easy  to fall into analysis paralysis with these system as well as become plagued with paranoia worrying about privacy. So for any system to stick, it has to be genuinely secure and simple.</p>
<p>There isn&#39;t a one-size-fits-all approach to a sizeable amount of things in life, knowledge management being one of these. Inquisitive individuals are likely to be rewarded for exploring different avenues, even unconventional ones, as they lead to a system tailored to one&#39;s personal way of thinking and working.</p>
<p>The path I eventually took, building my own, put an end to exhausting cyclical migrations. It helped me reclaim control over my privacy, and significantly cut down on recurring costs. Especially because it&#39;s ran from <a href="https://amberwilliams.io/blogs/vps-for-web-apps">my own VPS</a>. Commercial apps certainly offer out-of-the-box convenience, but by stepping back and focusing on what mattered to me, I found a way that worked profoundly better. <a href="https://amberwilliams.io/blogs/the-last-note-system">Here&#39;s how I built my PKMS step-by-step</a>. </p>
<p>After dogfooding my PKMS for over a year now, I&#39;m <a href="https://amberwilliams.io/blogs/bookmarks">capturing and connecting ideas</a> more efficiently than I ever managed with commercial apps. While I sometimes miss the community around commercial apps, I&#39;m hopeful I&#39;ll find a niche community of like minded individuals one day. </p>
<p>If my PKMS journey has resonated with you, or if you&#39;re on a similar path, I&#39;d genuinely love to connect. You can find me at any of the socials in the footer.</p>
<br/>
<br/>
<br/>

<hr>
]]></content>
      </entry>
      <entry>
        <title>The last note taking app you'll ever need</title>
        <link href="https://amberwilliams.io/blogs/the-last-note-system"/>
        <id>https://amberwilliams.io/blogs/the-last-note-system</id>
        <published>2025-04-03T15:50:13.840Z</published>
        <updated>2025-05-26T19:17:23.418Z</updated>
        <content type="html"><![CDATA[<p>It&#39;s deeply satisfying when I can maintain a high level of organization and productivity in my life. So naturally, I&#39;m intrigued by the single file productivity systems developers have been making <a href="https://jeffhuang.com/productivity_text_file">[1]</a> <a href="https://jodavaho.io/tags/bullet-journal.html">[2]</a>. <a href="https://obsidian.md/">Obsidian</a> is also a massive inspiration with its linked notes and plugins like <a href="https://github.com/blacksmithgu/obsidian-dataview">Dataview</a>.</p>
<p>The simplicity, flexibility &amp; centralization of these systems speak volumes to me for saving my todo lists, notes about things I&#39;ve read and the like. However note-taking apps such as Obsidian, which are closed source, felt finite and restricted. </p>
<p>Concerns around vendor lock-in are why I created my own notes system. I&#39;ve been using it as my core productivity framework for the past year and it&#39;s become indispensable to my work. Having the freedom to create custom tooling and integrations has impacted my day-to-day, and I’m excited to keep exploring its potential.</p>
<img style="width:100%;" alt="Notes app screenshot of a note on the author's project one-file" src="https://notes.holeytriangle.com/assets/12c5009a-8515-47e4-9d14-096f57d6d868">

<p>Features for the note taking app detailed in this guide include:</p>
<ul>
<li>Self-hosted</li>
<li>Private</li>
<li>Built to last</li>
<li>Low maintenance</li>
<li>Access in one place &amp; from any device (Obsidian charges for this <a href="https://obsidian.md/sync">feature</a>)</li>
<li>Versioning</li>
<li>Zero vendor lock-in</li>
<li>Extendable (eg. passing text-embedded notes to AI)</li>
</ul>
<h2>The digital hoarder</h2>
<p>Saving your notes digitally has many benefits. One being improving searchability across a corpus of notes. Now with advances in AI - notes saved digitally have the ability for smart categorization, summarization &amp; more.</p>
<p>Saving anything online comes with a few points of concern. Privacy and  provider lock-in are the most frequently discussed. Not to mention ensuring your notes don&#39;t get yeeted into the abyss when your computer stops working.</p>
<p>While these concerns are worrisome, they aren&#39;t enough for the mass population to go analog and use physical journals like it was the 1970s.</p>
<h2>Easier than you think</h2>
<p>I&#39;ve used Directus to go from 0 to 60mph to build a secure, flexible and easy to use notes web application. Don&#39;t worry we&#39;re not locked into using Directus. The database and data inside it are yours to port how ever you wish in the future.</p>
<p><a href="https://github.com/directus/directus">Directus</a> is a data platform built by Benjamin Haynes. It provides a real-time API and intuitive no-code interface for managing SQL database content. No paid affiliation, just a fan of the platform. It&#39;s source code is available to read through and is free to use (unless you are using it to make millions in revenue through the platform). </p>
<p>Let&#39;s dive into the setup.</p>
<h2>1. Hosting your app</h2>
<p>You&#39;ll need a place to host your dockerized Directus app to start. For those without their own VPS - I&#39;ve written an article <a href="https://amberwilliams.io/blogs/vps-for-web-apps">here</a> on the cheapest way to host your own web apps. </p>
<p>Optionally, there&#39;s <a href="https://fly.io/">Fly.io</a> or <a href="https://railway.com/">Railway</a> which make hosting web apps easy. Though there are trade-offs to hosting on these sort of providers as they are likely not <a href="https://web.archive.org/web/20220825160705/https://blog.heroku.com/next-chapter">long lived</a>. At the time of writing neither Fly.io and Railway have Docker Compose support so your set up will look slightly different than this guide outlines.</p>
<h2>2. Hosting your database</h2>
<p>We&#39;ll need a place to save our notes data into a database. Directus allows you to use a variety of SQL databases such as PostgreSQL and SQLite. In this guide we&#39;ll use PostgreSQL.</p>
<p>I initially started with a $15/mo managed postgres instance from Digital Ocean for a few months. Then moved to a containerized postgres instance I self-manage and backup.</p>
<p>Use the option which makes the most sense for you.</p>
<h2>3. Update app config</h2>
<p>Use the docker compose yaml code below to start from. Its config is using a containerized postgres instance that our notes app can use to store it&#39;s data. It also uses a containerized redis instance for caching.</p>
<p>For those with managed databases - comment out the <code>database</code> service &amp; <code>postgres_data</code> volume then update the database config in the <code>directus</code> service to use your managed database credentials.</p>
<pre><code class="language-yaml">    version: &quot;3&quot;
    services:
        database:
            image: postgres:15
            container_name: my_postgres
            restart: unless-stopped
            environment:
            POSTGRES_USER: &quot;directus_user&quot;
            # Add secure password here
            POSTGRES_PASSWORD: &quot;&quot;
            POSTGRES_DB: &quot;directus_db&quot;
            volumes:
            - postgres_data:/var/lib/postgresql/data
            ports:
            - &quot;5432:5432&quot;
            networks:
            - directus

        cache:
            container_name: cache
            image: redis:6
            networks:
            - directus
            restart: always

        directus:
            container_name: directus
            image: directus/directus:10.8.3
            ports:
            - 8000:8000
            volumes:
            - ./uploads:/directus/uploads
            networks:
            - directus
            depends_on:
            - cache
            restart: always
            environment:
            # Add key and secure secret
            KEY: &quot;&quot;
            SECRET: &quot;&quot;

            # Add your email &amp; secure default password here
            ADMIN_EMAIL: &quot;&quot;
            ADMIN_PASSWORD: &quot;&quot;

            # Add your email to EMAIL_FROM and EMAIL_SMTP_USER
            EMAIL_FROM: &quot;&quot;
            EMAIL_TRANSPORT: &quot;smtp&quot;
            EMAIL_SMTP_HOST: &quot;smtp-relay.brevo.com&quot;
            EMAIL_SMTP_PORT: &quot;587&quot;
            EMAIL_SMTP_USER: &quot;&quot;
            # Add secure password here
            EMAIL_SMTP_PASSWORD: &quot;&quot;


            # Uncomment below - for S3 file store
            # STORAGE_LOCATIONS: &quot;&quot;
            # STORAGE_DIGITALOCEAN_DRIVER: &quot;s3&quot;
            # STORAGE_DIGITALOCEAN_ROOT: &quot;/&quot;
            # STORAGE_DIGITALOCEAN_BUCKET: &quot;&quot;
            # STORAGE_DIGITALOCEAN_REGION: &quot;&quot;
            # STORAGE_DIGITALOCEAN_ENDPOINT: &quot;&quot;
            # STORAGE_DIGITALOCEAN_KEY: &quot;&quot;
            # STORAGE_DIGITALOCEAN_SECRET: &quot;&quot;

            # This uses postgres for database
            DB_CLIENT: &quot;pg&quot;
            DB_HOST: &quot;database&quot;
            DB_PORT: &quot;5432&quot;
            DB_DATABASE: &quot;&quot;
            DB_USER: &quot;&quot;
            DB_PASSWORD: &quot;&quot;

            CACHE_ENABLED: &quot;true&quot;
            CACHE_STORE: &quot;redis&quot;
            REDIS: &quot;redis://cache:6379&quot;

    networks:
        directus:

    volumes:
        postgres_data:
</code></pre>
<h2>4. Run your app</h2>
<p>Its as easy as saving the docker-compose file to your hosting server and running <code>docker compose up</code>. </p>
<p>Once that&#39;s running you can login to your app with the <code>ADMIN_</code> credentials from your docker-compose configuration.</p>
<h2>5. Saving your notes</h2>
<div class="rounded" style="max-width:500px;margin:0 auto;"><img 
                                                                 alt="Notes table schema rendered in the app UI"
                                                                 style="width:100%;" src="https://notes.holeytriangle.com/assets/aa110e73-9d6c-448b-8089-ed0f468ed6a8"></div>

<br/>

<p>I&#39;ve created a simple <code>Notes</code> table as shown. I went with Markdown language to save my notes in as it&#39;s widely used, has HTML support and multi-decade history.</p>
<p>If versioning is important to you be sure to select <code>enable versioning</code> on your Notes data model.</p>
<h2>Ending notes</h2>
<div class="rounded" style="max-width:500px;margin:0 auto;"><img 
                                                                 style="width:100%;"
                                                                 alt="Shows one-file note with its first entry in the app UI"
                                                                 src="https://notes.holeytriangle.com/assets/8e21c874-1931-4e46-bb58-a4d5530ebbd2"></div>

<br/>

<p>With this self-hosted notes system, you&#39;re now free from walled gardens and have full control over your notes.</p>
<p>As for my own notes app, I&#39;m currently working on a chrome extension to help bookmark and add notes to various articles I&#39;m reading. At some point, I&#39;d like to build my own flavor of Obsidian&#39;s <a href="https://obsidian.rocks/dataview-in-obsidian-a-beginners-guide/">Dataview</a> to work with a markdown file using <a href="https://mystmd.org/guide/frontmatter">Markdown frontmatter</a>.</p>
<p>If you do create your own notes system using these steps, I’d love to see what you’ve built - especially any plugins or extensions. You can find me at any of the socials in the footer.</p>
]]></content>
      </entry>
      <entry>
        <title>How to set up a VPS for hosting web apps</title>
        <link href="https://amberwilliams.io/blogs/vps-for-web-apps"/>
        <id>https://amberwilliams.io/blogs/vps-for-web-apps</id>
        <published>2025-04-02T15:08:42.351Z</published>
        <updated>2025-09-23T19:28:23.299Z</updated>
        <content type="html"><![CDATA[<p>In this guide I&#39;ll take you step-by-step how to set up a cheap and simple VPS to host your web apps. This approach works from hosting single html files to docker containers. </p>
<p><small>This guide supports Ubuntu as the operating system only.</small></p>
<h2>1.Spinning up a VPS</h2>
<p>Pick VPS that fits your system requirements. I generally go with the smallest option since providers allow you to scale up. I&#39;ve used Digital Ocean and Hetzner myself and would recommend either providers.</p>
<p>This guide uses SSH to work with the server. If you don&#39;t know how to SSH into the server <a href="https://www.digitalocean.com/community/tutorials/how-to-use-ssh-to-connect-to-a-remote-server">here&#39;s a guide on that</a>.</p>
<p>I recommend running a benchmark test when you set up a new VPS. You’ll sometimes get older machines or machines that have been heavily used when using VPSs. If the performance is degraded, delete your VPS from that provider and try again. I usually use <a href="https://github.com/masonr/yet-another-bench-script">Yet Another Bench Script</a> for this.</p>
<h2>2.Set up docker</h2>
<p>Digital ocean has option to spin up Droplet with Docker image pre-loaded. Your milage may vary with other providers.</p>
<p>For VPS without Docker installed follow this <a href="https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-18-04">guide</a> </p>
<h2>3.Nginx setup</h2>
<p>Once SSH&#39;d into the server run the below in the command line to update the system&#39;s packages and install nginx so we can route requests to the server.</p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install nginx 
# press enter to restart default services
</code></pre>
<p>Run the following to clear out nginx defaults</p>
<pre><code class="language-bash">cd /etc/nginx/sites-available/ &amp;&amp; rm default
cd /etc/nginx/sites-enabled/ &amp;&amp; rm default
</code></pre>
<h2>4.Setting up a firewall</h2>
<p>Providers often have firewall GUIs to simplify setting up a firewall. Here&#39;s an <a href="https://docs.hetzner.com/cloud/firewalls/getting-started/creating-a-firewall">article</a> on what you can expect setting up a firewall from a GUI to look like this one is from Hetzner&#39;s cloud offering.</p>
<p>If you prefer to set it up yourself I recommend this article <a href="https://www.digitalocean.com/community/tutorials/how-to-setup-a-firewall-with-ufw-on-an-ubuntu-and-debian-cloud-server">here</a>.</p>
<h2>5.Set up your site&#39;s maintenance page</h2>
<p>This is a page we&#39;ll use if your site offline. It&#39;s a good example that you can also use static files or static apps on your server. 
You can copy this starter one to test from <a href="https://gist.github.com/Amber-Williams/5b2e6b4049fa1b626f5da1e7cc005f13">here</a>.</p>
<p>Run the command below, it will open up an editor. Once the editor is up paste in your html, save and exit the editor.</p>
<pre><code class="language-bash">cd &amp;&amp; sudo mkdir /var/www/mysite.com
cd /var/www/mysite.com
sudo nano maintenance.html
</code></pre>
<h2>6.Routing network traffic</h2>
<p>Let&#39;s pretend you have a docker container running on port <code>8008</code>. Okay if you have an app running on that port or not since we have a maintaince page. We will now route traffic to your app running on the VPS from port <code>8008</code>. </p>
<p>Create an nginx config file for the site running:</p>
<pre><code class="language-bash">cd &amp;&amp; sudo nano /etc/nginx/sites-available/mysite.com
</code></pre>
<p>Now paste the following in the editor, save and then exit the editor:</p>
<pre><code class="language-nginx">	server {
	    # Port 80 is used for incoming http requests

	    # The URL we want this server config to apply to
	    server_name  mysite.com;

	    location / {
	      proxy_pass http://127.0.0.1:8008;
	    }
	        location @static {
	        # This is the maintaince page saved as /var/www/mysite.com/maintenance.html on the server
	        root /var/www/mysite.com;
	        try_files /maintenance.html =404;
	    }

	}
	server {
	    listen 80;
	    listen [::]:80;
	    server_name  mysite.com;
	}
</code></pre>
<h2>7.Enabling the site</h2>
<p>Run the following to create a symbolic link from your nginx config to enable the config.</p>
<pre><code class="language-bash">cd &amp;&amp; sudo ln -s /etc/nginx/sites-available/mysite.com /etc/nginx/sites-enabled/mysite.com
</code></pre>
<p>Now let&#39;s test we have set up the nginx config correctly via <code>sudo nginx -t</code>. If that looks good we can now restart the nginx server via <code>sudo systemctl restart nginx</code>. </p>
<p>Finally, go to your site via http (eg. <code>http://mysite.com</code>) in a browser - you should see your running app if its running or the maintenance page if not.</p>
<h2>8.Setting up HTTPS</h2>
<p>We will use <a href="https://github.com/certbot/certbot">Certbot</a>  for this. Important to note the certification expires after 90 days so good to have a cron job set up to renew this automatically. This article doesn&#39;t cover automating the renewal but there&#39;s a plethora out there that do.</p>
<pre><code class="language-bash">   snap install certbot --classic
   sudo apt-get install -y python3-certbot-nginx
   # ensure you have Nginx full added to firewall
   sudo certbot --nginx -d mysite.com
   # enter email &amp; choose not to share -&gt; then you&#39;re good to test a cert renewal 
   sudo certbot renew --dry-run
</code></pre>
<h2>Wrapping up</h2>
<p>I hope you found this guide easy to follow as it took me a few days to understand and get right myself. As a follow up, I recommend adding extra security set up to ensure your VPS is secure such as <a href="https://github.com/fail2ban/fail2ban">fail2ban</a>.</p>
<p>Please reach out if you think I missed anything! You can find me at any of the socials in site&#39;s footer.</p>
]]></content>
      </entry>
      <entry>
        <title>How Structured Output makes LLMs usable in production</title>
        <link href="https://amberwilliams.io/blogs/prod-ready-llms"/>
        <id>https://amberwilliams.io/blogs/prod-ready-llms</id>
        <published>2024-08-30T14:17:15.917Z</published>
        <updated>2025-09-24T18:59:40.142Z</updated>
        <content type="html"><![CDATA[<h1>How Structured Output makes LLMs usable in production</h1>
<blockquote>
<p>In mathematics, computer science and physics, a deterministic system is a system in which no randomness is involved in the development of future states of the system. - The Internet Encyclopedia of Science</p>
</blockquote>
<p>LLMs are capable of producing a consistent output when the input and model version all match. However, change a single word from that same prompt and the LLM can output something drastically different. Because of this, most LLMs are limited to features where a degree of randomness is acceptable, such as chat bots. For this same reason, integrating LLMs has historically been impractical for mission-critical operations.</p>
<p>The good news is that with <a href="https://python.langchain.com/docs/concepts/structured_outputs/">Structured Output</a> LLMs become exceedingly reliable and less random. Structured Output leverages strongly-typed systems to put guardrails around what an LLM can output. Furthermore, when its used in combination with performance testing - companies can  deliver stable AI-integrated products. </p>
<p>The reader can expect to walk away with an understanding of how to build dependable AI-integrations. The same concepts from this article were used to build <a href="https://amber-williams.github.io/hackernews-whos-hiring/">HN Who&#39;s Hiring Database</a>. Check out the original post on how the HN Who&#39;s Hiring Database website was was built <a href="https://amberwilliams.io/blogs/hn-hiring-threads-to-markdown">here</a>. It&#39;s a self-maintained open-source project - used by 100s of monthly active users to query Hacker News job postings. Hacker News is a social news website and online forum for computer science and entrepreneurship, run by the venture capital firm Y Combinator. </p>
<h2>Structured outputs</h2>
<p>At its core, Structured Output is a constraint mechanism for LLMs. It forces the model to adhere to a predefined schema, ensuring that the model&#39;s output follows a strict structure. For this article, I&#39;ll be using <a href="https://openai.com/index/introducing-structured-outputs-in-the-api/">OpenAI&#39;s API</a> in Python using Pydantic. The same can be done in Typescript with Zod and various other languages using JSON mode with JSON based schemas.</p>
<p>Clear advantages of structured output:</p>
<ul>
<li><strong>Type safety</strong>: No more runtime type errors from unexpected LLM outputs.</li>
<li><strong>Efficient prompting</strong>: No more verbose instructions in your prompts about output format.</li>
<li><strong>Less code overhead</strong>: No more complex regex rules to extract data.</li>
</ul>
<p>The example project for this article uses a cron job to scrape website data, gives large text blocks to an LLM to extract and clean data from, save and share the data online. We&#39;ll discuss only a portion of the project which uses LLM integration - to read more about those other parts check out the <a href="https://amberwilliams.io/blogs/hn-hiring-threads-to-markdown">blog post on how HN Who&#39;s Hiring Database</a> was built.</p>
<p>Let&#39;s have a look the data quality bump we can expect between LLM prompting <a href="https://github.com/Amber-Williams/hackernews-whos-hiring/blob/497be1aa61e7c0df02c30e7a566b50a09f53937f/table/2024/August.md">with Structured Output</a> and <a href="https://github.com/Amber-Williams/hackernews-whos-hiring/blob/5111cd9e06b892edcdbd53ba317825acf8b0abc8/table/2024/August.md">without</a>. Not only does the quality go up, but the text based prompt greatly decreased. The <a href="https://github.com/Amber-Williams/hackernews-whos-hiring/blob/6822442c474cb3c34ba3b2373ccfd9cfcdade978/chat_extractor.py#L18">original prompt&#39;s code with few-shot prompting</a> was a total <code>134</code> lines of code. With Structured Output <code>134</code> lines were reduced to <code>7</code>.</p>
<p>This reduction in prompting overhead is because the model uses the Structured Output schema as part of it&#39;s context. When schemas are named and related clearly, the model understands what it needs to do through a code based semantic meaning. You no longer need to explain linguistically to the model how a system&#39;s data model and and their relationships work.</p>
<p>That&#39;s not to say that Structured Output is always enough for the model to understand a system&#39;s data modeling. In these cases, we can add descriptions to the schema. JSON mode provides a way to add descriptions directly for this. However, Pydantic&#39;s built in <a href="https://docs.pydantic.dev/latest/api/fields/">field description</a> is not supported. To work around this OpenAI&#39;s appears to read the Pydantic schema&#39;s docstring so I find it&#39;s more performant to add schema context there rather than putting in your prompt. Typescript using JSDoc in the Zod model <em>may</em> work similarly.</p>
<h2>Let&#39;s see it in action</h2>
<pre><code class="language-python"># Pydantic structures file
from typing import Optional, List
from enum import Enum

class RemoteEnum(str, Enum):
    YES = &quot;yes&quot;
    NO = &quot;no&quot;
    HYBRID = &quot;hybrid&quot;
    UNKNOWN = &quot;unknown&quot;


class CurrencyEnum(str, Enum):
    USD = &quot;USD&quot;
    EUR = &quot;EUR&quot;
    GBP = &quot;GBP&quot;
    CAD = &quot;CAD&quot;
    CNY = &quot;CNY&quot;
    AUD = &quot;AUD&quot;
    CHF = &quot;CHF&quot;
    MXN = &quot;MXN&quot;
    UNKNOWN = &quot;unknown&quot;


class EmploymentType(str, Enum):
    FULL_TIME = &quot;full-time&quot;
    PART_TIME = &quot;part-time&quot;
    CONTRACT = &quot;contract&quot;
    INTERN = &quot;intern&quot;
    UNKNOWN = &quot;unknown&quot;


class JobPosting(BaseModel):
    comment_id: int
    company_name: str
    job_title: str
    employment_type: EmploymentType
    currency: CurrencyEnum
    remote: RemoteEnum
    salary: Optional[str]
    remote_rules: Optional[str]
    how_to_apply: Optional[str]
    company_city: Optional[str]
    company_country: Optional[str]
    languages_and_frameworks: Optional[List[str]]


class JobPostings(BaseModel):
    postings: List[JobPosting]
</code></pre>
<pre><code class="language-python">from openai import OpenAI

from structures import JobPostings

client = OpenAI()


completion = client.beta.chat.completions.parse(
    model=&quot;gpt-4o-2024-08-06&quot;,
    messages=[
        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;You are a data extraction expert that extracts job posting data based on a list of job postings you are provided.&quot;},
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;...&quot;}
    ],
    response_format=JobPostings,
)
job_postings = completion.choices[0].message.parsed
</code></pre>
<p>Now instead of JSON like typical REST requests, the response will a <code>JobPostings</code> Pydantic model. This not only adds a layer of validation, but can also be used to <a href="https://github.com/Amber-Williams/hackernews-whos-hiring/blob/497be1aa61e7c0df02c30e7a566b50a09f53937f/models/__init__.py#L53">format</a> or clean data further.</p>
<h2>Enabling reliable testing</h2>
<p>Before Structured Output, methods of extracting meaningful data from an LLM&#39;s wall of text, required excessive lines of regex.</p>
<p>Now that we have used Structured Output to enforce the shape of our data as opposed to a wall of tech - we can utilize test <strong>Evals</strong> to evaluate an LLM&#39;s performance. Evals are incredibly useful to validate new ideas, improve or lock-in AI integrated feature performance.</p>
<p>Before Structured Output, methods of extracting meaningful data from an LLM&#39;s wall of text, required excessive lines of regex. Testing an LLM&#39;s performance also prone to this same issue. Validating an LLM&#39;s wordy output required regex or similarity matching of text. Sometimes even more costly measures, like OpenAI&#39;s <a href="https://github.com/openai/evals">evals</a> which uses another LLM to score our LLM’s output. Which always sounded like a snake eating its own tail from my perspective. </p>
<p>Well this is all much, much easier now that we can limit the LLM to a simple Yes / No only output if we like.</p>
<div style="max-width:600px;margin:0 auto;">
  <center> <b>Turning your testing from looking like this...</b></center>
  <br/><br/>
<img style="max-width: 100%;" src="https://admin.holeytriangle.com/assets/97f34dfa-f87e-4635-9c99-d71d351d2f8c">
  
<p><br/><br/>
  <center><b>...into this...</b></center>
<br/><br/></p>
<table>
<thead>
<tr>
<th>LLM output</th>
<th>Truth</th>
<th>Eval</th>
</tr>
</thead>
<tbody><tr>
<td>apple</td>
<td>apple</td>
<td>✅</td>
</tr>
<tr>
<td>orange</td>
<td>banana</td>
<td>❌</td>
</tr>
</tbody></table>
  </div>
  
 
<p>Code below is how this could work in practice. You’ll notice there’s 2 similarity matching checks. This is where the output type would be considered an unstructured data type. When the output&#39;s longer than a sentence, we can generally consider as it as unstructured data.</p>
<pre><code class="language-python"># conftest.py - fixtures we’ll need for our tests
import pytest

from structures import JobPostings

JOB_POSTING_OUTPUT_FILE = &#39;job_posting_ouput.json&#39;


@pytest.fixture
def jobs_struct() -&gt; JobPostings:
    json_data = None
    script_dir = os.path.dirname(os.path.abspath(__file__))
    file_path = os.path.join(script_dir, JOB_POSTING_OUTPUT_FILE)

    with open(file_path, &#39;r&#39;) as f:
        json_data = json.load(f)
    if json_data is None:
        raise ValueError(&quot;No data found in the job_posting_ouput.json&quot;)
    return JobPostings(**json_data)
 
</code></pre>
<pre><code class="language-python">from fuzzywuzzy import fuzz

from structures import JobPosting


def test_response_passes_model_validation(jobs_struct):
    assert isinstance(assets_struct, JobPostings), &quot;JobPosting structure is invalid&quot;


def test_job_post_sample(jobs_struct):
    SOFT_ASSERT_THRESHOLD = 80  # 80% similarity threshold

    expected_job = JobPosting(
        comment_id=41133198,
        company_name=&quot;FusionAuth&quot;,
        job_title=&quot;Senior Java Software Engineer&quot;,
        employment_type=EmploymentType.FULL_TIME,
        currency=CurrencyEnum.USD,
        remote=RemoteEnum.HYBRID,
        salary=&quot;$120k-$180k&quot;,
        remote_rules=&quot;ONSITE or REMOTE in USA (location reqs listed on the job desc)&quot;,
        how_to_apply=&quot;Learn more, including about benefits and salaries, and apply here: https://fusionauth.io/jobs/&quot;,
        company_city=&quot;Denver&quot;,
        company_country=&quot;USA&quot;,
        languages_and_frameworks=[&quot;Java&quot;, &quot;MySQL&quot;, &quot;Docker&quot;]
    )
    job = jobs_struct[5]

    # Strict assertions
    assert job.comment_id == expected_job.comment_id, &quot;Comment ID does not match&quot;
    assert job.company_name == expected_job.company_name, &quot;Company name does not match&quot;
    assert job.job_title == expected_job.job_title, &quot;Job title does not match&quot;
    assert job.employment_type == expected_job.employment_type, &quot;Employment type does not match&quot;
    assert job.currency == expected_job.currency, &quot;Currency does not match&quot;
    assert job.remote == expected_job.remote, &quot;Remote does not match&quot;
    assert job.salary == expected_job.salary, &quot;Salary does not match&quot;
    assert job.company_city == expected_job.company_city, &quot;Company city does not match&quot;
    assert job.company_country == expected_job.company_country, &quot;Company country does not match&quot;

    # Soft assertions
    similarity = fuzz.ratio(job.remote_rules, expected_job.remote_rules)
    assert similarity &gt;= SOFT_ASSERT_THRESHOLD, f&quot;Similarity {similarity}% is below {SOFT_ASSERT_THRESHOLD}% for line:\nActual: {job.remote_rules}\nExpected: {expected_job.remote_rules}&quot;

    similarity = fuzz.ratio(job.how_to_apply, expected_job.how_to_apply)
    assert similarity &gt;= SOFT_ASSERT_THRESHOLD, f&quot;Similarity {similarity}% is below {SOFT_ASSERT_THRESHOLD}% for line:\nActual: {job.how_to_apply}\nExpected: {expected_job.how_to_apply}&quot;
</code></pre>
<h2>Long-term performance monitoring</h2>
<p>When integrating LLMs into production systems, it&#39;s crucial to track performance over time. This generally is done using multiple test sets. The test sets can be created from snapshots of human verified successful cases. The larger test sets will improve reliability as will extending the test set as time goes on, and adding edge cases to the test set as they arise.</p>
<p>Here&#39;s why this matters and how structured output plays a key role:</p>
<ul>
<li><strong>Detecting drift</strong>: LLM performance can drift over time due to changes in the underlying model, updates to prompt engineering, or shifts in user behavior.</li>
<li><strong>Seasonal variations</strong>: Some applications may have seasonal patterns. By maintaining separate test sets for different time periods (e.g., monthly), you can identify and account for these variations.</li>
<li><strong>Impact of updates</strong>: When you make changes to your prompts or fine-tuning, you need a way to measure the impact across different scenarios.</li>
<li><strong>Comprehensive evaluation</strong>: Different test sets can cover various edge cases, ensuring your LLM integration remains robust across a wide range of inputs.</li>
</ul>
<p>To demonstrate LLM evals put into practice, let&#39;s imagine that we have shipped an AI-integration that takes a customer email, when the customer&#39;s email mentions an issue with the product the AI-integration will create a support ticket for our team to look into. We built-in test evals and are actively tracking the accuracy of our LLM integration over the course of a year. Each week, we run our test suite and record the overall accuracy. </p>
<img style="width:100%;margin-bottom: 16px;" src="https://admin.holeytriangle.com/assets/867ab034-2247-40be-b81c-49cd75f1fe97">


<p>We notice a dramatic dip in week 52. We looked into our source code from that day and can see an engineer upgraded the model&#39;s versioning which lead a 20% reduction in performance. Without this tracking in place, such a regression can not only go unnoticed but also looses the ability to pin point causality.</p>
<p>Test evals allows companies to make data-driven decisions around prompt engineering, model providers or versions, and fine-tuning strategies. All while ensuring that LLM integration remains robust and effective over the long term.</p>
<h2>Wrapping things up</h2>
<p>We&#39;ve discussed how using Structured Outputs with LLMs adds stability to products using AI integration. Structured Output when used in combination with the right test evals, companies can start to integrate LLMs into production systems with confidence. </p>
<p>It&#39;s still not advisable to integrate critical systems with LLMs since, they cannot guarantee fully accurate data. However, LLMs can deliver greater consistency and efficiency than their human counter parts which otherwise become bored or tired. For projects that demand considerable manual efforts, LLM integrations are worth considering.</p>
<p>Feel free to reach out via any of the social links in the footer if this topic caught your interest. For those that enjoyed this article, you may also like to read how HN Who&#39;s Hiring Database was built <a href="https://amberwilliams.io/blogs/hn-hiring-threads-to-markdown">here</a>. </p>
]]></content>
      </entry>
      <entry>
        <title>The long-term payoff of building a component library</title>
        <link href="https://amberwilliams.io/blogs/component-library-long-term-payoff"/>
        <id>https://amberwilliams.io/blogs/component-library-long-term-payoff</id>
        <published>2024-06-23T20:49:03.483Z</published>
        <updated>2025-05-28T23:18:18.253Z</updated>
        <content type="html"><![CDATA[<h1>The long-term payoff of building a component library</h1>
<p><small><i><a href="https://github.com/Amber-Williams/component-library/">Repo link</a> for those that wish to dive straight into the code.</i></small>
<br/>
<br/>
As software engineers, we often find ourselves reinventing the wheel with each new project, particularly when it comes to UI components. This repetitive setup can be time-consuming and often leads to inconsistencies across projects. It&#39;s a challenge I&#39;ve faced repeatedly, both in my <a href="https://github.com/Amber-Williams/cultivator/">personal projects</a> and in professional settings.</p>
<p>To address this, I embarked on creating a custom component library. This approach has proven beneficial not only for solo developers like myself, but also for companies of various sizes. </p>
<p>Here&#39;s a handful of benefits off the top of my head:
<br/></p>
<div style="display: grid;grid-template-columns: 20% 80%;width: 100%;border: 2px solid #ffb370;border-radius: 8px;">
    <div style="padding: 8px;">
        <b>Solo Developers</b>
    </div>
    <div style="padding: 8px;">
        • Significantly reduces project setup time
        <br>• Ensures consistency across personal projects
        <br>• Allows focus on core functionality rather than UI basics
    </div>
    <div style="padding: 8px;border-top: 2px solid #ffb370;">
        <b>Companies</b>
    </div>
    <div style="padding: 8px;border-top: 2px solid #ffb370;">
        • Promotes a unified design language across products
        <br>• Improves team efficiency by reducing redundant work
        <br>• Simplifies onboarding for new team members
    </div>
</div>

<br/>
In this post, I'll walk you through my process of building a custom component library. While my specific choices may not fit everyone's needs, the overall approach can be adapted to suit different requirements and preferences.

<h2>1. Picking a base</h2>
<p>Selecting the right component library was crucial. I evaluated several options based on key factors important for my needs.</p>
<p>Libraries considered:</p>
<ul>
<li>Open-source: <a href="https://mui.com/material-ui/">MUI&#39;s Material-UI</a>, <a href="https://mantine.dev">Mantine</a>, <a href="https://ant.design/">Ant Design</a>, <a href="https://www.fast.design/">FAST (Microsoft)</a>, <a href="https://baseweb.design/">Base Web (UBER)</a>, <a href="https://polaris.shopify.com/">Polaris (Shopify)</a>, <a href="https://tamagui.dev/">Tamagui</a></li>
<li>Open-source &amp; Tailwind based: <a href="https://daisyui.com/">DaisyUI</a>, <a href="https://www.radix-ui.com/">Radix UI</a></li>
<li>Paid licensing: <a href="https://flowbite.com/">Flowbite</a>, <a href="https://tamagui.dev/bento/">Tamagui&#39;s Bento</a></li>
</ul>
<p>To guide my decision, I prioritized these factors:</p>
<div style="display: grid; grid-template-columns: repeat(3, 1.2fr); gap: 10px; width: 100%; max-width: 800px; margin: auto; border: 2px solid #ffb370; border-radius: 10px; padding: 16px;margin-bottom:2rem;">
  <div>• React integration</div>
  <div>• Open-source</div>
  <div>• Actively maintained</div>
  <div>• Extendable</div>
  <div>• Robust component collection</div>
  <div>• Built-in design system</div>
  <div>• Documentation</div>
  <div>• Form handling</div>
  <div>• Native Support</div>
</div>

<p>After careful consideration, I chose <a href="https://mui.com/material-ui/">MUI&#39;s Material-UI</a> as my base library. It stood out in several key areas, particularly in its extensive set of prebuilt components, robust documentation, and built-in design system based on <a href="https://m3.material.io/">Google&#39;s Material Design</a>. As a bonus there&#39;s a Figma base MUI provides for free <a href="https://www.figma.com/community/file/912837788133317724/material-ui-for-figma-and-mui-x/">here</a> that can be used prototype your projects ahead of build.</p>
<p>However, it&#39;s important to note that each base has its strengths, and what worked best for my needs might not be the optimal choice for everyone. I encourage you to evaluate these options based on your own criteria and project needs.</p>
<h2>2. Making it my own</h2>
<p>Next up, I tweaked the design system. Went for a kind of robotic text vibe, cooked up a color scheme that didn&#39;t make my eyes bleed, and made sure the dark and light modes looked related.</p>
<p>Here are examples of my customized themes:</p>
<div style="display:flex;">
<img style="width:50%;" src="https://admin.holeytriangle.com/assets/4b9a4150-7854-446e-b1f4-bc69b429a1f6">
<img style="width:50%;" src="https://admin.holeytriangle.com/assets/e563b7dd-d2cd-45dd-ba50-7ccea4a3ce10">
</div>
<br/>

<p>It&#39;s worth noting that while these customizations worked well for my projects, your needs may differ. The beauty of using a flexible design system like is that it allows for extensive customization generally from single config file.</p>
<p>Worth checking if your base component library has a <a href="https://zenoo.github.io/mui-theme-creator/">theme generator online</a> to speed this step up.</p>
<h2>3. Compile time</h2>
<p>Selecting the right Javascript module bundler was a crucial decision in the development of my component library. The most common JavaScript compilers I considered were <a href="https://rollupjs.org/">Rollup</a>, <a href="https://webpack.js.org/">Webpack</a> and <a href="https://parceljs.org/">Parcel</a>.</p>
<p>If you&#39;ve ever had to maintain an <a href="https://create-react-app.dev/docs/available-scripts/#npm-run-eject">ejected Create React App</a> you know how complex Webpack can get. Given my Webpack battle scars I wanted to explore my options before committing to a long term relationship with a compiler. This exploration led me to Rollup, which stood out for several reasons. The popularity trend against it&#39;s peers included.</p>
<img style="width:100%;" src="https://admin.holeytriangle.com/assets/e0802ab5-a00f-4ba5-a8be-5e59b6d283b1">

<h2>4. Package optimizations</h2>
<p>Initial package size was an eye-watering <code>32.7 MB</code> unpacked. I reduced this to <code>4.8 MB</code> by removing sourcemaps and including only one javascript module system (I went with CommonJS).</p>
<p>Note that <code>@mui/material</code> alone is 10.8 MB unpacked.</p>
<p>It&#39;s crucial to consider your package size and optimize for the smallest size that meets your needs. While <code>4.8 MB</code> is a significant improvement, there&#39;s certainly room for further optimization. The key is balancing functionality with performance.</p>
<h2>5. Component playground</h2>
<p>To streamline development and quickly iterate on components, I set up a local testing environment:</p>
<ul>
<li>Created a playground from a boilerplate React app</li>
<li>Pointed the <code>package.json</code> file to the locally built component library: <code>&quot;@mb3r/component-library&quot;: &quot;file:..&quot;</code></li>
<li>Added a Makefile with <code>build</code> and <code>test-build</code> commands for quick use</li>
</ul>
<p>This step is particularly useful for verifying component behavior before publishing a package.</p>
<h2>6. Publishing the package</h2>
<p>For distributing the component library, I chose to publish it on NPM, the most widely used registry for open-source Node.js packages. Here&#39;s how I approached it:</p>
<ul>
<li>Created a company-type namespace: <code>@mb3r</code></li>
<li>Named the component library: <code>component-library</code></li>
<li>Resulting package name: <code>@mb3r/component-library</code></li>
</ul>
<p>This approach allows for hosting multiple related packages under a single namespace, providing a clean and organized structure in users&#39; package.json files.</p>
<p>While NPM is ideal for open-source projects, the choice of registry depends on your specific requirements around privacy, integration with existing tools, and deployment processes. You may consider other package repository solutions such as <a href="https://aws.amazon.com/codeartifact/">AWS CodeArtifact</a>, <a href="https://docs.github.com/packages">GitHub Packages</a> or <a href="https://verdaccio.org/">Verdaccio</a> over NPM.</p>
<p>I update the versioning manually by running <code>npm version 1.0.0</code> then publish to NPM by running <code>npm publish</code>.</p>
<p>In future releases I plan to automate package versioning with <a href="https://github.com/commitizen-tools/commitizen">Commitizen</a>. Commitizen essentially is a wrapper around your repo&#39;s Git history and creates semantic versioning based on Git commits along with a host of other useful features like automating your changelog.</p>
<h2>Final thoughts</h2>
<p>The ultimate goal is to find the right balance between consistency, efficiency, and flexibility in your workflow for a component library.</p>
<p>Personally creating a custom component library has significantly streamlined my development process. For example, I was able to build a <a href="https://amberwilliams.io/blogs/bookmarks">web-clipper chrome extension</a> for my <a href="https://amberwilliams.io/blogs/building-my-own-pkms">PKMS / notes system</a> in under a day. Here&#39;s what it looks like as of late clipping a web quote to send to my PKMS:</p>
<div style="max-width:100%;margin:0 auto;">
<img class="rounded w-100 my-3"  alt="Clipping a web quote to send to my PKMS with Queso" src="https://notes.holeytriangle.com/assets/a9e33da0-53fa-43d2-a035-58951128f845">
 </div>

<br/>
<br/>

]]></content>
      </entry>
      <entry>
        <title>How I automated parsing Hacker News hiring threads into markdown</title>
        <link href="https://amberwilliams.io/blogs/hn-hiring-threads-to-markdown"/>
        <id>https://amberwilliams.io/blogs/hn-hiring-threads-to-markdown</id>
        <published>2024-01-08T20:06:30.156Z</published>
        <updated>2025-04-01T19:55:28.425Z</updated>
        <content type="html"><![CDATA[<p>Navigating the dense and often unstructured world of <a href="https://news.ycombinator.com/item?id=38490811">HackerNews hiring threads</a> can be akin to finding a needle in a haystack.</p>
<p>There&#39;s several similar solutions like <a href="https://kennytilton.github.io/whoishiring/">this</a> already, but let&#39;s build our own anyway.</p>
<p><a href="https://github.com/Amber-Williams/hackernews-whos-hiring">Github repo here</a></p>
<h2>Collecting Hacker News hiring tread data</h2>
<p>I first performed a Google search to find the latest HackerNews hiring threads for a specific month and year, and then extracted the top link to the target thread. Next, I used BeautifulSoup to scrape data from this thread, focusing on comments structured like job listings. Finally, I compiled these comments into a dictionary and continued scraping across multiple pages to gather all relevant hiring data.</p>
<p>I realize now there is an <a href="https://news.ycombinator.com/submitted?id=whoishiring">id to these sorts of threads</a> that makes the google search reduntant. Oh well</p>
<h2>Token Size Estimation and GPT Prompt Formulation</h2>
<p>Estimating the token size of each data point was done using OpenAI&#39;s <a href="https://github.com/openai/tiktoken">tiktoken</a>. This estimation was crucial for batching the rows into OpenAI requests, optimizing both speed and expenditure.</p>
<p>Then came the task of formulating the GPT prompt, which included:</p>
<ul>
<li><strong>Data Extraction</strong>: Transforming the raw scraped rows into structured columns of interest, like <code>salary</code>.</li>
<li><strong>Few-Shot Prompting</strong>: A technique to enable in-context learning where we provide demonstrations in the prompt to steer the model to better performance.</li>
<li><strong>JSON Mode</strong>: Enforcing output to conform to valid JSON</li>
<li><strong>Error Handling</strong></li>
</ul>
<h2>Processing, Cleaning</h2>
<p>Once the GPT had processed the data, it was time to rejoin the batched data into a cohesive set. This step was followed by a thorough cleaning process, ensuring the data was accurate and useful.</p>
<h2>Automation</h2>
<p>To keep this process up-to-date without <em>monthly</em> manual intervention, I set up a monthly GitHub Action cron job. This job scrapes the latest data, processes it, and commits the updated table data to a repository, with a link provided in the README. Which finally opens an easy to manage <a href="https://github.com/Amber-Williams/hackernews-whos-hiring/pull/5">pull request</a> assigned to me.</p>
<h2>Putting it all together</h2>
<pre><code class="language-mermaid">graph TB
    AAA[scrape hackernews] --&gt; AA{Token counter} --&gt; A
    A[scraped.csv] -. chunk data .-&gt; B(batch-0.csv)
    A -. chunk data .-&gt; C(batch-1.csv)
    A -. chunk data .-&gt; D(batch-2.csv)

    B -..-&gt; Q[Processing Queue]
    C -..-&gt; Q
    D -..-&gt; Q

    Q --&gt; E{LLM System
 data extraction}
    E -. batch n .-&gt; E

    E --&gt; | Rejoin processed batches | F[summary.csv]
    F --&gt; G{Clean data}
    G --&gt; H[clean.csv]
</code></pre>
<h2>Imperfect results</h2>
<p>This project, while not perfect and certainly in need of improvements, showcases the practical application of web scraping, AI, and automation in extracting and analyzing data from unstructured sources. It was an engaging journey to automate this process, highlighting how these technologies can be effectively utilized for insightful data analysis. Looking ahead, I aim to refine the system and compare parsed data over time to reveal emerging trends.</p>
<img style="width:100%;" src="https://admin.holeytriangle.com/assets/bc0a8fb2-707c-49e1-bcb0-950925a5a575">]]></content>
      </entry>
</feed>