<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Raymond Camden</title>
    <link href="https://www.raymondcamden.com/feed.xml" rel="self" type="application/atom+xml"></link>
    <link href="https://www.raymondcamden.com/" rel="alternate" type="text/html"></link>
    <subtitle>Father, husband, developer relations and web standards expert, and cat demo builder.</subtitle>

    <updated>2026-06-11T22:01:26+00:00</updated>
    <author>
        <name>Raymond Camden</name>
        <email>raymondcamden@gmail.com</email>
    </author>
    <id>https://www.raymondcamden.com/feed.xml</id>

    <generator>Eleventy</generator>

        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/11/testing-diffbots-web-search-api</id>
                <title>Testing Diffbot&apos;s Web Search API</title>
                <updated>2026-06-11T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/11/testing-diffbots-web-search-api" rel="alternate" type="text/html" title="Testing Diffbot&apos;s Web Search API"/>
                <content type="html">
				
                        &lt;p&gt;It&apos;s hard to believe I first experimented with &lt;a href=&quot;https://www.diffbot.com/&quot;&gt;Diffbot&lt;/a&gt; nearly five years ago. You can see that first post up on the Adobe Medium account - &lt;a href=&quot;https://medium.com/adobetech/natural-language-processing-adobe-pdf-extract-and-deep-pdf-intelligence-31ae07139b66&quot;&gt;Natural Language Processing, Adobe PDF Extract, and Deep PDF Intelligence&lt;/a&gt;. Since then I&apos;ve tested out various APIs and features from them and was lucky enough to connect with them recently about a new initiative, a &lt;a href=&quot;https://docs.diffbot.com/reference/web-search-get&quot;&gt;web search API&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There&apos;s multiple examples of this out in the wild already, but most just scrape/hack against Google. Google &lt;em&gt;had&lt;/em&gt; an API, the Custom Search JSON API (I even &lt;a href=&quot;https://www.raymondcamden.com/2020/03/22/implementing-google-custom-search-engines-json-api-in-the-jamstack&quot;&gt;covered it&lt;/a&gt; back when folks still talked about the JAMStack) but the API is now deprecated and officially turning off January 1, 2027.&lt;/p&gt;
&lt;p&gt;Diffbot&apos;s API (which quietly launched about two weeks ago) is against their own crawled index. Why does this matter? Honestly the docs do a &lt;em&gt;damn&lt;/em&gt; good job of explaining why you should care (emphasis mine):&lt;/p&gt;
&lt;p&gt;&amp;quot;Candidates are retrieved and reranked with a cross encoder model trained to rank &lt;strong&gt;factual relevance over popularity&lt;/strong&gt;, primary sources over domain monopolies, and organic page rank over &lt;strong&gt;paid spend&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The net effect is search results that &lt;strong&gt;assume the user is not an idiot&lt;/strong&gt;. A search for &amp;quot;hypothyroidism&amp;quot; returns relevant research study papers alongside content from publicly administered websites and reputable non-profit institutions.&amp;quot;&lt;/p&gt;
&lt;p&gt;Assuming users aren&apos;t idiots?!?!&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/cotton.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Bold move cotton&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Alright, so what&apos;s the API actually like? Cribbing from the docs, you make a &lt;code&gt;GET&lt;/code&gt; to &lt;code&gt;https://llm.diffbot.com/api/v1/web_search/&lt;/code&gt; with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your search term as &lt;code&gt;text&lt;/code&gt; in a query param&lt;/li&gt;
&lt;li&gt;A max number of results via &lt;code&gt;size&lt;/code&gt; that defaults to 10&lt;/li&gt;
&lt;li&gt;An optional &lt;code&gt;maxTokens&lt;/code&gt; value related to processing of the results&lt;/li&gt;
&lt;li&gt;Lastly, a Diffbot key passed via an &lt;code&gt;Authorization&lt;/code&gt; header&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As of &lt;em&gt;today&lt;/em&gt;, the API itself has no limits:&lt;/p&gt;
&lt;p&gt;&amp;quot;Usage is currently unmetered while we open Web Search API up for public testing.
Don&apos;t be the person to screw this up for everyone.&amp;quot;&lt;/p&gt;
&lt;p&gt;I&apos;d expect in the future this will change and I&apos;d check their main &lt;a href=&quot;https://docs.diffbot.com/reference/rate-limits&quot;&gt;rate limits doc&lt;/a&gt; for the latest info.&lt;/p&gt;
&lt;p&gt;As for actually using it, they&apos;ve got a great &lt;a href=&quot;https://github.com/diffbot/diffbot-python&quot;&gt;Python SDK&lt;/a&gt; which makes this incredibly simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from diffbot import Diffbot

db = Diffbot(token=&amp;quot;YOUR_TOKEN&amp;quot;)
results = db.web_search(&amp;quot;diffbot knowledge graph&amp;quot;)
for r in results[&amp;quot;search_results&amp;quot;]:
    print(r[&amp;quot;score&amp;quot;], r[&amp;quot;title&amp;quot;], r[&amp;quot;pageUrl&amp;quot;])
    print(r[&amp;quot;content&amp;quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But with the REST API being so simple, I thought I&apos;d build a quick demo in JavaScript and use my new favorite home for stuff like this, &lt;a href=&quot;https://val.town&quot;&gt;val.town&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;building-a-web-search-alert&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#building-a-web-search-alert&quot;&gt;Building a Web Search Alert&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So this is probably the &lt;em&gt;simplest&lt;/em&gt; implementation (and in the last hour the Diffbot team literally added new stuff to the docs, which means more content, woot) but I built a simple system that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Once a week, hits the Diffbot Web Search API looking for a term&lt;/li&gt;
&lt;li&gt;Filters the results based on the score value to only consider higher quality results&lt;/li&gt;
&lt;li&gt;Emails it to me&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You, my highly intelligent reader, are probably already thinking of ways to tweak that, and as I said, I plan to follow up on this (tomorrow probably), but here&apos;s the entirety of the system in one file:&lt;/p&gt;
&lt;iframe width=&quot;100%&quot; height=&quot;600px&quot; src=&quot;https://www.val.town/embed/x/raymondcamden/diffbot-search-alert/main.ts&quot; title=&quot;Val Town&quot; frameborder=&quot;0&quot; allow=&quot;web-share&quot; allowfullscreen&gt;&lt;/iframe&gt;
&lt;p&gt;I&apos;ve got my key in an environment variable I snag first, and then define some core values:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;My search term (and yes, you can craft more complex queries than just my name)&lt;/li&gt;
&lt;li&gt;A max number of results&lt;/li&gt;
&lt;li&gt;A filter to apply on the results&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then it&apos;s just a matter of hitting the endpoint. I take the results and filter based on my minimum desired value (scores go from 0 to 1 and in my testing, 0.5 helped).&lt;/p&gt;
&lt;p&gt;Each result contains the URL of the result, the title, the score, and a portion of the content. For my email, I show each of the values, but I was a bit torn on how to handle the &lt;code&gt;content&lt;/code&gt; value. It&apos;s markdown and I can easily turn Markdown into HTML, but it&apos;s a part of a web page and greatly screws up an HTML email.&lt;/p&gt;
&lt;p&gt;So how does it look?&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/df1.png&quot; loading=&quot;lazy&quot; alt=&quot;Email example&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Ok, that&apos;s not &lt;em&gt;terribly&lt;/em&gt; exciting, but you get the idea. I think where this starts to get cool is in combination with other tools as well. You will probably &lt;em&gt;not&lt;/em&gt; be surprised to know this is covered in their &lt;a href=&quot;https://github.com/diffbot/diffbot-skills&quot;&gt;AI skills&lt;/a&gt; as well, and I&apos;m going to demonstrate that soon as well. Check it out for yourself and let me know what you think!&lt;/p&gt;

                        
                
				</content>

                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/07/can-genai-help-you-win-in-vegas-2026</id>
                <title>Can GenAI help you win in Vegas? (2026)</title>
                <updated>2026-06-07T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/07/can-genai-help-you-win-in-vegas-2026" rel="alternate" type="text/html" title="Can GenAI help you win in Vegas? (2026)"/>
                <content type="html">
				
                        &lt;p&gt;No! Thanks for reading. Still here? Cool. So &lt;em&gt;way&lt;/em&gt; back in 2023, I built a little demo that I thought was kinda cool. Early in 2023 I had used Alpine.js and the excellent &lt;a href=&quot;https://deckofcardsapi.com/&quot;&gt;Deck API&lt;/a&gt; to build a simple web-based Blackjack game: &lt;a href=&quot;https://www.raymondcamden.com/2023/07/04/creating-a-blackjack-game-with-alpinejs-and-the-deck-of-cards-api&quot;&gt;Creating a Blackjack Game with Alpine.js and the Deck of Cards API&lt;/a&gt;. I then took &lt;em&gt;that&lt;/em&gt; demo and connected to Google&apos;s GenAI API, which back then was named Palm: &lt;a href=&quot;https://www.raymondcamden.com/2023/11/09/can-genai-help-you-win-in-vegas&quot;&gt;Can GenAI help you win in Vegas?&lt;/a&gt;. It&apos;s been a few years and I thought I&apos;d take a stab at it again, this time making use of Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;Prompt API&lt;/a&gt;. Technically I&apos;d have much better luck using a bleeding edge Gemini model via a serverless function, and I may still take a stab at that later, but I thought I&apos;d see how well the Prompt API worked.&lt;/p&gt;
&lt;p&gt;I won&apos;t go into all the details on how the game is built, for that, check out the &lt;a href=&quot;https://www.raymondcamden.com/2023/07/04/creating-a-blackjack-game-with-alpinejs-and-the-deck-of-cards-api&quot;&gt;original post&lt;/a&gt;. Also, I won&apos;t go over again the UI/UX of AI prompting, which I covered in the &lt;a href=&quot;https://www.raymondcamden.com/2023/11/09/can-genai-help-you-win-in-vegas&quot;&gt;last post&lt;/a&gt;. All I did was add a button: &amp;quot;Ask for Help!&amp;quot;, which kicks off the process and then uses a JavaScript &lt;code&gt;alert&lt;/code&gt; to provide feedback. That&apos;s not awesome but I&apos;m fine with that. Ok, I lie, it&apos;s Sunday, I&apos;ve got nothing but laundry to do - so I&apos;ll replace it with a native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; instead!&lt;/p&gt;
&lt;p&gt;To enable this new version, I made a couple of changes. First, I&apos;ve got a new Alpine method to create the AI session. This is responsible for setting up the system instruction for the model as well:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async createSession() {
	console.log(&apos;createSession running&apos;);
	this.session = await LanguageModel.create({
		expectedOutputs: [
			{ type:&apos;text&apos;, languages: [&apos;en&apos;] }
		],
		initialPrompts: [
			{
				role: &apos;system&apos;,
				content:
							`
You are an expert Blackjack advisor helping a player make decisions in a simplified game (no splitting, no doubling down, no surrender). Your goal is to advise the player whether to &apos;Hit&apos; or &apos;Stand&apos; based on standard basic Blackjack strategy.

CRITICAL GAME RULES:
- Goal: Beat the dealer&apos;s hand value without exceeding 21 (busting).
- Card Values: Cards 2-10 are face value. Jack, Queen, and King are worth 10. Aces are worth either 1 or 11, whichever is more beneficial without busting.
- Blackjack: A &amp;quot;Blackjack&amp;quot; is strictly a 2-card hand consisting of an Ace and a 10-value card (10, J, Q, K). An Ace and an 8 is just 19, not a Blackjack.
- Soft vs. Hard: A hand with an Ace counted as 11 is a &amp;quot;soft&amp;quot; hand (e.g., Ace + 6 = soft 17). Otherwise, it is a &amp;quot;hard&amp;quot; hand.

INSTRUCTIONS:
You will be provided with the player&apos;s current hand and the dealer&apos;s visible card.
Respond strictly in the following format:
ACTION: [Choose ONLY &amp;quot;Hit&amp;quot; or &amp;quot;Stand&amp;quot;]
REASON: [A brief, one-sentence logical explanation based on standard Blackjack probabilities and the dealer&apos;s visible card]
`,
		},
	],
	monitor(m) {
		m.addEventListener(&apos;downloadprogress&apos;, (e) =&amp;gt; {
			if (e.loaded === 0) return;
			const pct = Math.floor(e.loaded * 100);
			if(pct !== 0 &amp;amp;&amp;amp; pct !== 100) this.aiHelp = `Downloading model… ${pct}%`;
		});
	},
});			
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the level of detail in the system instruction. Initially I had just the initial paragraph and a bit about how to return the results. I took this to the Gemini desktop app and asked this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;I’ve built a web based Blackjack game. It’s a simplified version that doesn’t 
include splitting or doubling down. As part of the game, I used Chrome’s 
built-in Prompt API to offer advice to the player on whether to hit or stand. 
This works *mostly* ok, but I see mistakes, for example, Chrome’s Prompt API 
once said an Ace + 8 is a blackjack, when it has to be a face card or 10 as 
far as I know. To help me improve the prompt, can you give me a good system 
prompt that briefly, but completely, covers the rules, again though ignoring 
doubling down/splitting/etc. This is the current system instruction:

(I pasted the first version here)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I then took its improved prompt and added it to my code.&lt;/p&gt;
&lt;p&gt;Next I had to wire this up to the AI advice button. This is the &lt;code&gt;askForHelp&lt;/code&gt; method and the referenced schema:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const adviceSchema = {
  type: &apos;object&apos;,
  properties: {
    action: { type: &apos;string&apos;, enum:[&apos;HIT&apos;,&apos;STAND&apos;] },
    reason: { type: &apos;string&apos; }
  },
  required: [&apos;action&apos;, &apos;reason&apos;]
};

async askForHelp() {
	console.log(&apos;lets call an (AI) friend&apos;);
	if(!this.session) {
		console.log(&apos;generating a session&apos;);
		this.aiHelp = &apos;&amp;lt;i&amp;gt;Creating the AI session.&amp;lt;/i&amp;gt;&apos;;
		await this.createSession();
	}
	this.aiHelp = &apos;&amp;lt;i&amp;gt;Asking our AI friend...&amp;lt;/i&amp;gt;&apos;;

	let totalOb = this.getCount(this.playerCards);

	// string to represent my hand, could be 2-5 cards
	let playerStr = &apos;My hand consists of:\n &apos;;
	// this could be reduce(), im being lazy
	this.playerCards.forEach(p =&amp;gt; {
		playerStr += `${p.value} of ${p.suit.toLowerCase()}\n`;
	});
	
	// string to set the total value for the prompt, helps it
	let totalStr = &apos;My hand has a total of &apos;;
	if(totalOb.lowCount === totalOb.highCount) {
		totalStr += totalOb.lowCount;
	} else {
		totalStr += `${totalOb.lowCount} or ${totalOb.highCount}`;
	}
	
	let content = `
I&apos;m playing blackjack and the dealer currently has ${this.pcCards[1].value} of ${this.pcCards[1].suit.toLowerCase()}.

${playerStr}

${totalStr} 

Should I hit or should I stay?
	`;
	console.log(&apos;content&apos;, content);
	let result = JSON.parse(await this.session.prompt(
		[{ role: &apos;user&apos;, content }], 
		{ responseConstraint: adviceSchema }
));
	console.log(result);
	
	let resultMsg = `
You should ${result.action}. Why?
${result.reason}
`;

	this.aiHelp = &apos;&apos;;
	this.advice = resultMsg;
	this.$refs.adviceDialog.showModal();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ok, there&apos;s a few things of note going on here. As with the previous version, I take the current game state and turn it into a prompt where I describe my hand. While working on this version I discovered a pretty big flaw from the previous game. When asking AI for advice, I only described two cards max. Now I ensure I describe my entire hand.&lt;/p&gt;
&lt;p&gt;Now you may be wondering, why do I get the total value of my hand - shouldn&apos;t the model know how to calculate that? As before, at least in the Prompt API, the answer is no. Initially I removed that bit thinking the system instruction would be enough. But even though my system prompt describes how to get the total of a hand, I still found the model calculating the total wrong. Not all the time, but at least once. So the little helper string went back in.&lt;/p&gt;
&lt;p&gt;The other change in this version is a proper schema to shape the response - give me an action (hit or stay) and a reason. This gets turned into a string and passed to my HTML dialog element. Here&apos;s how it looks in action:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/bj2.png&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot from BJ app&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;In case that&apos;s hard to read, the dialog says:&lt;/p&gt;
&lt;p&gt;&amp;quot;You should HIT. Why? With a hard 16, you need to improve your hand to stand a chance against the dealer&apos;s strong 10.&amp;quot;&lt;/p&gt;
&lt;p&gt;If I remember right, this advice makes sense, but I find it difficult personally to hit on anything 15 and higher. For that particular round I stayed and the dealer won with Blackjack, so I had no chance.&lt;/p&gt;
&lt;p&gt;In my next test, the dealer was showing a King of Clubs and I had two Aces and the advice was:&lt;/p&gt;
&lt;p&gt;&amp;quot;You should STAND. Why? You have a soft 2 or 12 which is a strong hand, and the dealer has a King, making it unlikely they will have a high hand.&amp;quot;&lt;/p&gt;
&lt;p&gt;That seems crazy, so I went ahead and got a new card which ended up being a seven. At that point the AI suggested staying, which I did, and I still lost as the dealer&apos;s other card was a 10.&lt;/p&gt;
&lt;p&gt;In my next round, the dealer was showing a queen and I had a 5 and a 9. The AI suggested hitting which I completely agreed with and of course - I busted.&lt;/p&gt;
&lt;p&gt;I played a few more times, and generally the model&apos;s suggestions seemed ok. I could see it being useful if I was a complete novice at the game, but I don&apos;t see whipping out my phone and asking AI for help any time soon. (I don&apos;t really game myself, but I assume most casinos have rules against this now. Anyone know offhand?)&lt;/p&gt;
&lt;p&gt;What do you think? If you&apos;ve got an up to date Chrome, you can test it here: &lt;a href=&quot;https://codepen.io/cfjedimaster/full/KwNbvPq/afbf4079fe199863f11f75957817fcc8&quot;&gt;https://codepen.io/cfjedimaster/full/KwNbvPq/afbf4079fe199863f11f75957817fcc8&lt;/a&gt;. Note that the first time you download the model it may take 5 to 10 minutes, but that applies across your device as a whole. If you run across another demo using the API you won&apos;t need to download it again.&lt;/p&gt;
&lt;p&gt;If you want to take a look at the code, I&apos;ve embedded it below. The dialog I added could &lt;em&gt;really&lt;/em&gt; use some styles so if anyone wants to fork and share, let me know!&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-height=&quot;500&quot; data-pen-title=&quot;Blackjack (Chrone AI)&quot; data-default-tab=&quot;html&quot; data-slug-hash=&quot;KwNbvPq&quot; data-user=&quot;cfjedimaster&quot; data-token=&quot;afbf4079fe199863f11f75957817fcc8&quot; style=&quot;height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/cfjedimaster/pen/KwNbvPq/afbf4079fe199863f11f75957817fcc8&quot;&gt;
  Blackjack (Chrone AI)&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                <category term="javascript" />
                
                <category term="alpinejs" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/02/ai-versus-a-grue</id>
                <title>AI versus a Grue</title>
                <updated>2026-06-02T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/02/ai-versus-a-grue" rel="alternate" type="text/html" title="AI versus a Grue"/>
                <content type="html">
				
                        &lt;p&gt;&amp;quot;It is pitch dark. You are likely to be eaten by a grue.&amp;quot; If you recognize that quote, congrats, you probably need to see your doctor about pain in your back. (Ok, pain everywhere.) For those of us of a certain age, or folks who like classic games, the Infocom series was a tremendous source of entertainment. Using plain text commands to navigate your environment, players had to use their minds to figure out puzzles and mazes in order to win the game. The IF (Interactive Fiction) community is still strong and of course, I&apos;ve spoken before about my time playing and coding on MUDs, so with that in mind, I thought I&apos;d try tackling something that I assumed probably wouldn&apos;t work well - seeing if Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/built-in&quot;&gt;built-in AI&lt;/a&gt; could handle playing Zork 1, the most famous of Infocom&apos;s library. The short answer is... not so well. But honestly, it was kind of fun to try so I figured I&apos;d share the results.&lt;/p&gt;
&lt;h2 id=&quot;getting-zork&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#getting-zork&quot;&gt;Getting Zork&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before I could even begin trying to test AI&apos;s ability to play a 50 year old game (almost 50, Zork 1 launched in 1977), I first needed to find a JavaScript implementation. This turned out to be a bit tricky.&lt;/p&gt;
&lt;p&gt;I quickly found &lt;a href=&quot;https://github.com/curiousdannii/ifvms.js&quot;&gt;ifvms.js&lt;/a&gt;, an interactive fiction virtual machine library, but it was tailored to running at the command line. Also, there was little to no documentation on how to make use of the library. The repo mentions another repo, &lt;a href=&quot;https://github.com/curiousdannii/parchment&quot;&gt;parchment&lt;/a&gt; which lets you play games on the web, but I struggled to find documentation there as well, or documentation specific to my use case.&lt;/p&gt;
&lt;p&gt;To help, I went to Claude, and had it load &lt;code&gt;ifvms.js&lt;/code&gt; and create a &apos;stubbed&apos; version that I could use via calls like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// all of this loads Zork 1...
const res = await fetch(&apos;zork1.b64.txt&apos;);
const b64 = await res.text();

const binary = atob(b64.trim());
const arrayBuffer = new ArrayBuffer(binary.length);
const view = new Uint8Array(arrayBuffer);
for (let i = 0; i &amp;lt; binary.length; i++) {
    view[i] = binary.charCodeAt(i);
}

engine = new ZorkEngine();
engine.load(arrayBuffer);

// and then later:
let gameResponse = engine.getOutput(); // last thing the game did

// send a command
let lastResponse = engine.send(command);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That source file above mentioned in the first line comes from an online source. Zork itself is open source (&lt;a href=&quot;https://github.com/historicalsource/zork1&quot;&gt;https://github.com/historicalsource/zork1&lt;/a&gt;), but I forget where I got my copy of the source. It&apos;s not a text file, I renamed it to that when initially attempting to build my demo on CodePen. I believe it was pure binary I converted to base64 and then renamed.&lt;/p&gt;
&lt;h2 id=&quot;building-the-game&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#building-the-game&quot;&gt;Building the Game&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So with Claude helping me build the version of &lt;code&gt;ifvms&lt;/code&gt; I could use interactively, I then had to whip up a quick UI and point Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;Prompt API&lt;/a&gt;, which as of Chrome 148 is GA to everyone. (Well, everyone on Chrome.)&lt;/p&gt;
&lt;p&gt;My UI was just a giant text area where I&apos;d stream the output from the game and Chrome&apos;s inputs. All of that code is kinda basic, so I&apos;ll focus on the prompt aspect. Here&apos;s how I create my session:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;$session = await LanguageModel.create({
    initialPrompts:[
        { 
            role: &apos;system&apos;, 
            content: 
                    `
You are playing the classic text adventure game Zork. You will be given the current game output, and you will 
respond with a single command to play the game. You will not explain your command, just give the command. 

Remember to keep your commands concise and to the point, as if you were playing the game yourself. Do not include 
any extra text or explanations.

Zork is a text-based adventure game where you explore a mysterious underground world filled with treasures, puzzles, 
and dangers. Your goal is to navigate through the various locations, solve puzzles, and collect treasures 
while avoiding traps and monsters.

Here are some examples of commands you might use in Zork:
- &amp;quot;go north&amp;quot;
- &amp;quot;take lamp&amp;quot;
- &amp;quot;open mailbox&amp;quot;
- &amp;quot;look around&amp;quot;
- &amp;quot;inventory&amp;quot;

Do your best to play the game effectively and efficiently, making smart choices based on the current game output.

Try not to keep returning to the same location repeatedly, as that can lead to getting stuck in loops. Instead, 
explore new areas and try different actions to progress through the game. Remember that you can travel in all directions.
                    `
        }
    ],		
    monitor(m) {
            m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
                console.log(`Downloaded ${e.loaded * 100}%`);
                if(e.loaded === 0 || e.loaded === 1) {
                    $output.innerHTML = &apos;&apos;;
                    return;
                }
                $output.innerHTML = `Downloading AI model, currently at ${Math.floor(e.loaded * 100)}%`;
            });
        }		
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see, a majority of this is the big prompt. I iterated over this multiple times to help guide Chrome to effectively solve the game. (Which to be clear, it never did.)&lt;/p&gt;
&lt;p&gt;Outside of that, the main loop of the game is this heartbeat function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function gameHeartBeat() {

    console.log(&apos;in hb&apos;);
    let gameResponse = engine.getOutput();
    if(!gameResponse) gameResponse = lastResponse;

    if(gameResponse) {
        $gameoutput.value += gameResponse + &apos;\n&apos;;
        $gameoutput.scrollTop = $gameoutput.scrollHeight;

        let command = await $session.prompt(gameResponse);
        lastResponse = engine.send(command);

        $gameoutput.value += `&amp;gt; ${command}\n`;
        $gameoutput.scrollTop = $gameoutput.scrollHeight;

        console.log(&apos;Resp&apos;,command);
        setTimeout(gameHeartBeat, 1000 * 2);
    }
 
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The variable &lt;code&gt;$gameoutput&lt;/code&gt; is just a DOM pointer to the &lt;code&gt;textarea&lt;/code&gt; used to render the game.&lt;/p&gt;
&lt;p&gt;Before going any further, I&apos;ll note a huge issue with my code and that&apos;s the lack of session management. Chrome absolutely provides you a way to monitor that usage and re-init the session when it is nearly filled. I didn&apos;t bother with that because I was being lazy when I built this.&lt;/p&gt;
&lt;p&gt;I&apos;d also argue that we could help Chrome&apos;s model quite a bit by keeping track of a list of rooms it has already visited to help it remember and not loop over the same place. My prompt tries to do that, but I could also help it out myself.&lt;/p&gt;
&lt;p&gt;All in all - this was a quick implementation and the results were... well what you would expect with a lightweight model and me just hacking quickly on it. ;)&lt;/p&gt;
&lt;p&gt;Here&apos;s a transcript from a test I just did.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ZORK I: The Great Underground Empire
Infocom interactive fiction - a fantasy story
Copyright (c) 1981, 1982, 1983, 1984, 1985, 1986 Infocom, Inc. All rights reserved.
ZORK is a registered trademark of Infocom, Inc.
Release 119 / Serial number 880429

West of House
You are standing in an open field west of a white house, with a boarded front door.
There is a small mailbox here.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 0
&amp;gt; examine mailbox
The small mailbox is closed.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 1
&amp;gt; open mailbox
Opening the small mailbox reveals a leaflet.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 2
&amp;gt; read leaflet
(Taken)
&amp;quot;WELCOME TO ZORK!

ZORK is a game of adventure, danger, and low cunning. In it you will explore some of the most amazing territory ever seen by mortals. No computer should be without one!&amp;quot;

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 3
&amp;gt; look around
West of House
You are standing in an open field west of a white house, with a boarded front door.
There is a small mailbox here.

&amp;gt;                                                                                 West of HouseScore: 0  Turns: 4
&amp;gt; go north
North of House
You are facing the north side of a white house. There is no door here, and all the windows are boarded up. To the north a narrow path winds through the trees.

&amp;gt;                                                                                 North of HouseScore: 0  Turns: 5
&amp;gt; go north
Forest Path
This is a path winding through a dimly lit forest. The path heads north-south here. One particularly large tree with some low branches stands at the edge of the path.

&amp;gt;                                                                                 Forest PathScore: 0  Turns: 6
&amp;gt; examine tree
There&apos;s nothing special about the tree.

&amp;gt;                                                                                 Forest PathScore: 0  Turns: 7
&amp;gt; go north
Clearing
You are in a clearing, with a forest surrounding you on all sides. A path leads south.
On the ground is a pile of leaves.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 8
&amp;gt; take leaves
In disturbing the pile of leaves, a grating is revealed.
Taken.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 9
&amp;gt; open grating
The grating is locked.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 10
&amp;gt; look around
Clearing
You are in a clearing, with a forest surrounding you on all sides. A path leads south.
There is a grating securely fastened into the ground.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 11
&amp;gt; go south
Forest Path

&amp;gt;                                                                                 Forest PathScore: 0  Turns: 12
&amp;gt; go north
Clearing

&amp;gt;                                                                                 ClearingScore: 0  Turns: 13
&amp;gt; examine leaves
There&apos;s nothing special about the pile of leaves.

&amp;gt;                                                                                 ClearingScore: 0  Turns: 14
&amp;gt; go south
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my tests, it never seems to get much further in the game than the outer area, but certainly tries hard, so I guess I&apos;ll give it a gold star for that.&lt;/p&gt;
&lt;p&gt;If you want to try this yourself, you can point Chrome at &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/chrome_zork/&quot;&gt;https://cfjedimaster.github.io/webdemos/chrome_zork/&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you want to peruse the code yourself, you can check it out here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/chrome_zork&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/chrome_zork&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Now that I&apos;ve got a bit more time on my hands (ahem), I may try hacking on this a bit more, but it was certainly fun to see it in action.&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@sortino?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Joshua Sortino&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/photo-of-light-towards-inside-of-cave-rAqzj79GUmA?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/01/creating-a-heavy-rain-alert-with-raindrop-and-val-town</id>
                <title>Creating a Heavy Rain Alert with RainDrop and Val Town</title>
                <updated>2026-06-01T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/01/creating-a-heavy-rain-alert-with-raindrop-and-val-town" rel="alternate" type="text/html" title="Creating a Heavy Rain Alert with RainDrop and Val Town"/>
                <content type="html">
				
                        &lt;p&gt;One of the &amp;quot;joys&amp;quot; of living in Louisiana is the rainy season, which is heavier in spring but honestly, feels like it lasts all year long. I can still remember being on a business trip in 2016, about to fly home, and hearing about some sort of &apos;rain event&apos; back in Louisiana. This surprised me as there wasn&apos;t a hurricane involved, just an incredible amount of rain (up to 2-3 inches &lt;strong&gt;per hour&lt;/strong&gt;). You can read more about it on the &lt;a href=&quot;https://en.wikipedia.org/wiki/2016_Louisiana_floods&quot;&gt;Wikipedia page&lt;/a&gt; about the event, but it goes without saying - rain is a big deal down here.&lt;/p&gt;
&lt;p&gt;A while back I discovered &lt;a href=&quot;https://www.raindrop.farm/&quot;&gt;RainDrop&lt;/a&gt;, which is an app and a web site that lets you check on rainfall totals. You can see my zipcode here, &lt;a href=&quot;https://www.raindrop.farm/rainfall-totals/zipcode/70508#map&quot;&gt;https://www.raindrop.farm/rainfall-totals/zipcode/70508#map&lt;/a&gt;, and while we aren&apos;t having any rain this second, we&apos;ve got some forecast for today.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/raindrop1c.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot from RainDrop&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;When I discovered the site, I did some digging and discovered they had an API: &lt;a href=&quot;https://api.raindrop.farm/docs&quot;&gt;https://api.raindrop.farm/docs&lt;/a&gt;. This API gives you &lt;em&gt;incredibly&lt;/em&gt; detailed information about rainfall (and snow) and can even return historical data.&lt;/p&gt;
&lt;p&gt;Given an API key passed in an &lt;code&gt;Authorization&lt;/code&gt; header, this gets the current precipitation for my location:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://api.raindrop.farm/v1/precipitation/current?lat=30.216667&amp;amp;lon=-92.033333
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which returns (as of 11:34 AM, and I just heard thunder):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &amp;quot;lat&amp;quot;: 30.216667,
    &amp;quot;lon&amp;quot;: -92.033333,
    &amp;quot;timestamp&amp;quot;: &amp;quot;2026-06-01T16:30:00Z&amp;quot;,
    &amp;quot;precipitationIntensity&amp;quot;: 0.0
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ok - slight delay here - I just got out of a meeting, and the rain is here:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &amp;quot;lat&amp;quot;: 30.216667,
    &amp;quot;lon&amp;quot;: -92.033333,
    &amp;quot;timestamp&amp;quot;: &amp;quot;2026-06-01T17:06:00Z&amp;quot;,
    &amp;quot;precipitationIntensity&amp;quot;: 1.2999999523162842
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That value is in millimeters per hour so it looks like just a trickle, so nothing to worry about. Of course, what if it &lt;em&gt;was&lt;/em&gt; something to worry about? I thought - why not automate checking for this using my go to platform lately, &lt;a href=&quot;https://www.val.town/&quot;&gt;Val Town&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-app&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-app&quot;&gt;The App&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Alright, so the app I had in mind was fairly simple:&lt;/p&gt;
&lt;p&gt;Every X minutes, use the RainDrop API to check the rain intensity and if it is over a certain threshold, send an alert my way.&lt;/p&gt;
&lt;p&gt;My RainDrop API access had pretty high usage limits (120 a minute) that were above what made sense to realistically check. On the other hand, my free tier level at Val Town capped me at one execution per fifteen minutes.&lt;/p&gt;
&lt;p&gt;To be honest, once every fifteen minutes seemed perfectly reasonable, but keep in mind that both RainDrop and Val Town would support much higher frequencies if necessary.&lt;/p&gt;
&lt;p&gt;Given the above, I created a new val and set the trigger to Cron, set to every 15 minutes:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/raindrop2.png&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot from Val Town showing the Cron settings&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I then created an environment variable for my RainDrop key. With that in place, the entire &amp;quot;early warning flood system&amp;quot; (that&apos;s what I&apos;ll call it when doing interviews for my next job) is one file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { email } from &amp;quot;https://esm.town/v/std/email&amp;quot;;

/**
 * Converts speed from millimeters per hour to inches per hour.
 * @param {number} mmPerHour - Speed in millimeters per hour.
 * @returns {number} Speed in inches per hour.
 */
function mmPerHourToInchesPerHour(mmPerHour) {
  return mmPerHour / 25.4;
}

function dtFormat(timestamp) {
  const date = new Date(timestamp);

  const options = {
    weekday: &amp;quot;long&amp;quot;,
    year: &amp;quot;numeric&amp;quot;,
    month: &amp;quot;long&amp;quot;,
    day: &amp;quot;numeric&amp;quot;,
    hour: &amp;quot;2-digit&amp;quot;,
    minute: &amp;quot;2-digit&amp;quot;,
    timeZoneName: &amp;quot;short&amp;quot;,
  };

  // Create the formatter (e.g., for US English)
  const formatter = new Intl.DateTimeFormat(&amp;quot;en-US&amp;quot;, options);

  return formatter.format(date);
}

export default async function (interval: Interval) {
  // Lafayette, LA
  const lat = &amp;quot;30.216667&amp;quot;;
  const lng = &amp;quot;-92.033333&amp;quot;;

  // The threshold value is inches of rain per hour - any value &amp;gt;= will trigger a warning
  const threshold = 0.5;

  let key = Deno.env.get(&amp;quot;API_KEY&amp;quot;);
  let precipReq = await fetch(
    `https://api.raindrop.farm/v1/precipitation/current?lat=${lat}&amp;amp;lon=${lng}`,
    {
      headers: {
        &amp;quot;Authorization&amp;quot;: key,
      },
    },
  );

  let precip = await precipReq.json();
  let rainInches = mmPerHourToInchesPerHour(precip.precipitationIntensity);
  console.log(&amp;quot;rainInches&amp;quot;, rainInches);
  console.log(precip);

  if (rainInches &amp;gt;= threshold) {
    let html = `
&amp;lt;h2&amp;gt;Heavy Rain Alert&amp;lt;/h2&amp;gt;

&amp;lt;p&amp;gt;
As of ${
      dtFormat(precip.timestamp)
    }, we have detected ${rainInches.toFixed(2)} inches of rain per hour.
&amp;lt;/p&amp;gt;
    `;

    await email({
      subject: &amp;quot;Rain Alert&amp;quot;,
      html,
    });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On top of my script are two utility functions with the interesting one being the simple wrapper to convert RainDrop&apos;s values to inches.&lt;/p&gt;
&lt;p&gt;After that, I hit the API, check against a threshold (currently set to half an inch), and if it matches, I fire off an email. As a reminder, I&apos;m using Val Town&apos;s built in &amp;quot;mail the owner&amp;quot; feature. I could also use a proper email API to have more control over the delivery, but this works well for now.&lt;/p&gt;
&lt;p&gt;And of course, as soon as I built this there were no events, which is &lt;em&gt;good&lt;/em&gt; I suppose, but I did a quick tweak to force it to email and here&apos;s an example. Not terribly thrilling with 0 inches:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/raindrop3.png&quot; loading=&quot;lazy&quot; alt=&quot;Email example&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;If you want to play with this, you can fork the val here: https://www.val.town/x/raymondcamden/raindropAlert. And of course if you do, let me know!&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@r_shayesrehpour?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;reza shayestehpour&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/grayscale-photography-of-raindrops-Nw_D8v79PM4?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="serverless" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/27/looking-for-my-next-role-hire-me</id>
                <title>Looking for My Next Role - Hire Me</title>
                <updated>2026-05-27T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/27/looking-for-my-next-role-hire-me" rel="alternate" type="text/html" title="Looking for My Next Role - Hire Me"/>
                <content type="html">
				
                        &lt;p&gt;Yep, I get to write this post again...&lt;/p&gt;
&lt;p&gt;Well, today I find myself in the same position I&apos;ve been a few times over the past few years. Webflow went through a company-wide restructuring today and as part of that re-org, my role (and many others) were eliminated. So once again - I&apos;m asking my faithful readers to help me find my next role. You can find my &lt;a href=&quot;/resume&quot;&gt;resume&lt;/a&gt; if you need the latest edition and feel free to contact me with any opportunities you may have!&lt;/p&gt;

                        
                
				</content>

                
                
                <category term="uncategorized" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/20/dang-good-office-parsing-on-the-web-with-officeparser</id>
                <title>Dang good Office parsing on the web with officeParser</title>
                <updated>2026-05-20T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/20/dang-good-office-parsing-on-the-web-with-officeparser" rel="alternate" type="text/html" title="Dang good Office parsing on the web with officeParser"/>
                <content type="html">
				
                        &lt;p&gt;A few weeks ago I wrote about using Chrome&apos;s built-in AI support to summarize documents - &lt;a href=&quot;https://www.raymondcamden.com/2026/04/17/summarizing-docs-with-built-in-ai&quot;&gt;&amp;quot;Summarizing Docs with Built-in AI&amp;quot;&lt;/a&gt;. This was a followup on an earlier post that was PDF only and made use of an excellent library, &lt;a href=&quot;https://officeparser.harshankur.com/&quot;&gt;officeParser&lt;/a&gt;, to work with Microsoft Office files. This library worked well, but had one issue that made it a bit harder to use.&lt;/p&gt;
&lt;p&gt;Parsing a doc itself was super easy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const getAST = async (file, config) =&amp;gt; (await OfficeParser.parseOffice(file, config));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the issue I ran into was taking that result and turning it into something meaningful for Chrome&apos;s model to analyze. PDFs supported a &lt;code&gt;toText()&lt;/code&gt; method but for other formats I had to do a bit of work to get a text value. For example, here&apos;s the code I used to turn an Excel file into CSV:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let arrayBuffer = await file.arrayBuffer();
let data = await getAST(arrayBuffer, {});

const getCSV = s =&amp;gt; {
    let result = &apos;&apos;;
    let rows = s.children.filter(c =&amp;gt; c.type === &apos;row&apos;);
    console.log(rows[0]);
    rows.forEach(r =&amp;gt; {
        let data = [];
        let cells = r.children.filter(c =&amp;gt; c.type === &apos;cell&apos;);
        cells.forEach(c =&amp;gt; data.push(c.text));
        result += data.join(&apos;, &apos;) + &apos;\n&apos;;

    });
    return result;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yeah that&apos;s not bad, but it did mean a bit of work for each of the different Office types.&lt;/p&gt;
&lt;p&gt;Well, the good news is Harsh Ankur (the author of this awesome library) released a &lt;a href=&quot;https://github.com/harshankur/officeParser/blob/master/CHANGELOG.md&quot;&gt;v7&lt;/a&gt; that added a &lt;code&gt;to&lt;/code&gt; method that supports converting files to CSV, Markdown, RTF, PDF, Text, and HTML. Even better, his HTML support isn&apos;t just &amp;quot;convert to Markdown then render the Markdown to html&amp;quot;, but proper support straight from the parsed doc to create a richer result.&lt;/p&gt;
&lt;p&gt;I went ahead and built a demo you can use to test right now (and this demo doesn&apos;t use any of the Chrome AI stuff). I created a simple HTML file with a file field and 4 tabs: Text, Markdown, HTML, CSV. You can select a PDF or Office file and it will render into each of the tabs (well, only Excel will use CSV). The HTML isn&apos;t interesting, but here&apos;s the JavaScript:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { OfficeParser } from &apos;https://esm.sh/officeparser&apos;;

let $sourceFile;
let $textResult, $mdResult, $htmlResult, $csvResult;

const supportedFiles = [&apos;pdf&apos;,&apos;xlsx&apos;,&apos;docx&apos;,&apos;pptx&apos;];

document.addEventListener(&apos;DOMContentLoaded&apos;, init, false);
async function init() {
	console.log(&apos;lets go&apos;);
	$sourceFile = document.querySelector(&apos;#sourceFile&apos;);
	$textResult = document.querySelector(&apos;#textResult&apos;);
	$mdResult = document.querySelector(&apos;#mdResult&apos;);
	$htmlResult = document.querySelector(&apos;#htmlResult&apos;);
	$csvResult = document.querySelector(&apos;#csvResult&apos;);

	$sourceFile.addEventListener(&apos;change&apos;, filePreview, false);
}

async function filePreview(e) {
	const file = e.target.files[0];
	if (!file) return;
	let ext = file.name.split(&apos;.&apos;).pop();
	if(!supportedFiles.includes(ext)) return;
	let arrayBuffer = await file.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	
	let text = await data.to(&apos;text&apos;);
	$textResult.innerText = text.value;
	
	let md = await data.to(&apos;md&apos;);
	$mdResult.innerText = md.value;
	
	let html = await data.to(&apos;html&apos;);
	$htmlResult.contentDocument.open();
	$htmlResult.contentDocument.write(html.value);
	$htmlResult.contentDocument.close();

	if(ext === &apos;xlsx&apos;) {
		$csvResult.innerText = (await data.to(&apos;csv&apos;)).value;
	} else $csvResult.innerText = &apos;Only used for Excel files.&apos;;
}

const getAST = async (file, config) =&amp;gt; (await OfficeParser.parseOffice(file, config));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can play with this below:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;600&quot; data-pen-title=&quot;officeParser demo&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;NPbpNqp&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019e3d03-91b9-7e58-a88f-b730b7371377&quot;&gt;
  officeParser demo&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;p&gt;So - given that - I took a look at my previous &lt;a href=&quot;https://codepen.io/cfjedimaster/pen/MYjxbrv&quot;&gt;demo&lt;/a&gt; and modified the code to make use of these new features. Here&apos;s the new code that gets the text version of a document and passes it to the AI model:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;document.querySelector(&apos;#upload&apos;).addEventListener(&apos;change&apos;, async (e) =&amp;gt; {
    $output.innerHTML = &apos;&apos;;
    const file = e.target.files[0];
    if (!file) return;
    
    let ast = await getAST(await file.arrayBuffer());

    let summary = {
        title: ast.metadata?.title ?? &apos;No Title&apos;,
        text: (await ast.to(&apos;md&apos;)).value
    }

    doSummary(summary);

});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compared to the previous version, this one is over a hundred lines shorter. As I said... ddddaaaaannnnnngggg. You can try out this version below, but note that it is Chrome only. (If folks ask, or heck, even if you don&apos;t ask, I may build a Transformer.js version.)&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;600&quot; data-pen-title=&quot;Chrome AI, Doc Summaries (V3)&quot; data-preview=&quot;true&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;PwbpLJQ&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/cfjedimaster/pen/PwbpLJQ&quot;&gt;
  Chrome AI, Doc Summaries (V3)&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/19/my-first-ai-skill-for-my-blog</id>
                <title>My First AI Skill for My Blog</title>
                <updated>2026-05-19T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/19/my-first-ai-skill-for-my-blog" rel="alternate" type="text/html" title="My First AI Skill for My Blog"/>
                <content type="html">
				
                        &lt;p&gt;I&apos;ve been a professional writer now for thirty plus years, and honestly, it&apos;s one of the things I&apos;m most proud about. When generative AI first exploded on the scene, a lot of people used it to help them write, and frankly, that wasn&apos;t for me. I&apos;m not the best writer, but I damn well know how to write and damn well know my own voice. That being said, I&apos;ve been really interested in how GenAI can &lt;em&gt;help&lt;/em&gt; with the process.&lt;/p&gt;
&lt;p&gt;I first wrote about this over two years ago: &lt;a href=&quot;https://www.raymondcamden.com/2024/02/02/using-generative-ai-as-your-content-assistant&quot;&gt;Using Generative AI as Your Content Assistant&lt;/a&gt;. In that post I talked about using GenAI for two very specific tasks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Helping with my titles&lt;/li&gt;
&lt;li&gt;Writing the description (which is part of the metadata for a post)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Honestly, I built those tools as proof of concept implementations and don&apos;t think I used them ever again. But lately it&apos;s been on my mind that I should think about those tools and see if there would possibly be an easier way to use GenAI to help me with my content. Specifically I&apos;m looking for two things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spelling and grammar. I&apos;ve got a Visual Studio Code extension for that but it&apos;s not perfect and I also just miss it sometimes.&lt;/li&gt;
&lt;li&gt;And here&apos;s the big one. Sometimes I&apos;ll write about a topic and simply forget to cover something. I&apos;d love to use my AI tool to try to find those missing aspects and let me know. Maybe I won&apos;t care. Maybe I &lt;em&gt;intentionally&lt;/em&gt; skipped something. That being said, I&apos;d still like to know.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You&apos;ll notice that none of the above involves AI actually &lt;em&gt;writing&lt;/em&gt; for me. Outside of correcting spelling mistakes I made, I don&apos;t want to use my tool for that. But both of these could really help me catch things before I publish and just make the final result a little bit better.&lt;/p&gt;
&lt;h2 id=&quot;the-skill&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-skill&quot;&gt;The Skill&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To build this, I made use of &lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview&quot;&gt;Claude Skills&lt;/a&gt;. Skills are persistent instructions that can be used across your entire environment or per project. They&apos;re written in simple Markdown and are stored in a particular folder depending on your use. I actually cheated a bit and used Claude itself to help me create the skill.&lt;/p&gt;
&lt;p&gt;I knew about skills, knew they needed to be in a particular folder, but rather than looking it up I simply asked Claude to help me create a skill based on the needs above. It did an admirable job but I took over and tweaked things a bit.&lt;/p&gt;
&lt;p&gt;The directory is in my repository now, at &lt;code&gt;.claude/skills/prepublish&lt;/code&gt;. Here&apos;s what it looks like now (I say &apos;now&apos; as I expect to be tweaking this over time - you can also check out the &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023&quot;&gt;repo&lt;/a&gt; to see the latest!):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: prepublish
description: Review a new blog post before publishing. Finds the latest post by its dated path under `src/posts/`, then spell-checks and offers content suggestions — without touching tone.
---

# Prepublish blog post review

Run this right before pushing a new post. Blog posts live in `src/posts/YYYY/MM/DD/` as `.md` files.

## Step 1 — Find the post

Do NOT use git to figure out which post is newest. Use the dated path under `src/posts/` — sort highest year → month → day and pick the most recent `.md` file.

A reliable command: `find src/posts -type f -name &apos;*.md&apos; | sort -r | head -5`. The first entry is the latest post; the rest are there in case there&apos;s a tie or the user wants to pick another.

If multiple `.md` files share the newest date directory, ask the user which one. Otherwise, confirm in one line (&amp;quot;Reviewing `path/to/post.md` — sound right?&amp;quot;) before proceeding.

## Step 2 — Spell check

Read the post and surface misspellings. Ignore code blocks, fenced code, inline `code`, URLs, and proper nouns the user clearly intended (product names, people, libraries).

Present findings as a short list: `word → suggestion (line N)`. Don&apos;t auto-edit. Ask which corrections to apply, then apply them with Edit.

## Step 3 — Content review

Read the post end-to-end and give feedback focused on substance, not tone:

- **Things that sound stupid or unclear** — sentences that don&apos;t land, claims that need backing, awkward logic jumps.
- **Gaps worth filling** — missing context a reader would want, an example that would help, a link the user usually includes (docs, repo, related post).
- **Factual / technical accuracy** — flag anything that looks wrong or outdated. If the post references code, check it makes sense.
- **Loose ends** — TODO markers, &amp;quot;fill this in&amp;quot;, broken-looking links, placeholder text.

**Do NOT** comment on tone, voice, snark level, casual phrasing, or stylistic choices. The user likes their voice; leave it alone.

Present feedback as a numbered list grouped by category. Keep each item to one or two sentences. Don&apos;t rewrite paragraphs — point at the issue and let the user decide.

## Step 4 — Apply changes

Ask which suggestions to act on. Apply only what&apos;s confirmed. Then stop — the user handles the actual publish/push.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;an-example-in-action&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#an-example-in-action&quot;&gt;An Example in Action&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To test, I&apos;m going to run this right now on this post, which means it may get a bit confused as the post isn&apos;t actually done, but let&apos;s just see what happens:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/claude1.png&quot; loading=&quot;lazy&quot; alt=&quot;Results&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Hopefully that image is readable, let me know if not. But it did a great job of finding multiple grammar issues. My VS Code extension doesn&apos;t handle that (obviously).&lt;/p&gt;
&lt;p&gt;I disagreed/didn&apos;t care about the awkward comment. I did care about linking to Claude Skills so I added a link. Gap 3 was incorrect. I do talk about how I &apos;cheated&apos; (to be clear, it isn&apos;t cheating) in the following paragraph. And of course, issue 5 is simply because I ran this before I was done. Item 8 is the same.&lt;/p&gt;
&lt;p&gt;You&apos;ll notice it also offered to correct those mistakes, but I did them myself and I imagine I&apos;ll do so usually.&lt;/p&gt;
&lt;p&gt;In the end, these are all things a good editor would handle, but this is my personal blog and unfortunately I don&apos;t have an editor on call. (And even so, I&apos;d want to do this kind of check beforehand anyway.)&lt;/p&gt;
&lt;h2 id=&quot;what-next%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-next%3F&quot;&gt;What next?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Looking at the skill I wrote, and recognizing my own weaknesses (grammar, forgetting to cover things I should), I may actually move this skill into my global directory so I can use it for my non-blog writing as well. Anybody want to chime in and share if they&apos;ve done something similar?&lt;/p&gt;
&lt;p&gt;p.s. I ran the skill one more time - here&apos;s the latest:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/claude1.png&quot; loading=&quot;lazy&quot; alt=&quot;Final Results&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@ningdamao?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;宁 宁&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-cat-peacefully-sits-on-a-stack-of-papers-xDDkC_odjbU?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/17/links-for-you-51726</id>
                <title>Links For You (5/17/26)</title>
                <updated>2026-05-17T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/17/links-for-you-51726" rel="alternate" type="text/html" title="Links For You (5/17/26)"/>
                <content type="html">
				
                        &lt;p&gt;Happy Sunday, and I hope your Sunday is going better than mine. One of my kids just went to use the dryer and sparks flew. I don&apos;t mean metaphorically. Nothing like the thought of replacing another major appliance to brighten up your day, amiright?!?!&lt;/p&gt;
&lt;h2 id=&quot;find-your-(tech)-community&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#find-your-(tech)-community&quot;&gt;Find Your (Tech) Community&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is a new endeavour by Brian Rinaldi to help fill the gap left behind by Meetup.com (which is still a thing just not a good thing lately) and the lack of smaller and more affordable community conferences. &lt;a href=&quot;https://devrelish.tech/&quot;&gt;DevRel(ish)&lt;/a&gt; is a community site supporting tech groups of all sizes and nature who need help organizing IRL meetups. According to the &lt;a href=&quot;https://remotesynthesis.com/posts/introducting-devrelish/&quot;&gt;launch announcement&lt;/a&gt;, this is not meant to be a replacement for larger event apps, but hopefully something more suitable for smaller groups.&lt;/p&gt;
&lt;p&gt;Check it out at &lt;a href=&quot;https://devrelish.tech/&quot;&gt;https://devrelish.tech/&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;blame-the-ox&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#blame-the-ox&quot;&gt;Blame the Ox&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, this isn&apos;t tech related at all, but, &amp;quot;The Ox That&apos;s Breaking Your Fantasy Map&amp;quot; is one of the most fascinating videos I&apos;ve seen in a while. And yes, it is about oxen - and fantasy maps. Even if you don&apos;t like fantasy (or oxen), it&apos;s a great watch and reminder of just how the economy affects organization and government.&lt;/p&gt;
&lt;lite-youtube videoid=&quot;MIqpvpNS5pI&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/MIqpvpNS5pI/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=MIqpvpNS5pI&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;h2 id=&quot;ai-and-goblins&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#ai-and-goblins&quot;&gt;AI and Goblins&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Last up is a good look into why &lt;a href=&quot;https://openai.com/index/where-the-goblins-came-from/&quot;&gt;goblins started appearing&lt;/a&gt; in OpenAI models. The post details how initial reports of the behavior led to investigations that ultimately led to the smoking &lt;strike&gt;goblin&lt;/strike&gt; gun.&lt;/p&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Speaking of fantasy, I recently remembered that my young kids still have yet to see the Never Ending Story. One of them really loves horses too. This is going to be great!&lt;/p&gt;
&lt;lite-youtube videoid=&quot;2WN0T-Ee3q4&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/2WN0T-Ee3q4/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=2WN0T-Ee3q4&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/15/is-it-hotter-or-colder-this-year</id>
                <title>Is it hotter or colder this year?</title>
                <updated>2026-05-15T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/15/is-it-hotter-or-colder-this-year" rel="alternate" type="text/html" title="Is it hotter or colder this year?"/>
                <content type="html">
				
                        &lt;p&gt;Where I live could generously be called &amp;quot;warm&amp;quot;, but is usually closer to the surface of the sun, especially in late summer. That&apos;s why when the weather is &lt;em&gt;not&lt;/em&gt; oppressively hot, I try my best to enjoy it. We&apos;re mid-May now and honestly, this spring has been... pleasant. Suspiciously pleasant but I&apos;ll take what I can get.&lt;/p&gt;
&lt;p&gt;The last few weeks I&apos;ve been telling myself that the weather must be a good bit cooler than last year, and I finally decided to do something about it. I worked with Claude and created a little web app that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lets you enter a free form address and then use &lt;a href=&quot;https://www.geocod.io/&quot;&gt;Geocoding&lt;/a&gt; to convert it to a proper longitude and latitude. This is a super simple geocoding API with a generous free tier. Do note though it&apos;s North America only.&lt;/li&gt;
&lt;li&gt;Uses the &lt;a href=&quot;https://pirateweather.net/en/latest/&quot;&gt;Pirate Weather&lt;/a&gt; API to get historical weather information. Date wise, I&apos;m using this week, and then the same days over the previous four days.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As I mentioned, I worked with Claude on this and let it design the layout and write the code initially. I was kinda impressed by one part - the &lt;code&gt;mapWithConcurrency&lt;/code&gt; function that lets you pass an array of async function with a desired max number to run at once. It handles doing the batching and returning the final result. That makes the calls for the weather data a bit more gentle on the provider.&lt;/p&gt;
&lt;p&gt;However - I noticed it was taking a &lt;em&gt;long&lt;/em&gt; time to finish. In theory, I&apos;m doing 7 times 5 (this week plus four previous years) of calls which is 35 which doesn&apos;t &lt;em&gt;seem&lt;/em&gt; like a lot, but I did some digging. Claude had used an endpoint that was a bit old. Doing some more research I switched to the proper endpoint... which didn&apos;t support CORS.&lt;/p&gt;
&lt;p&gt;Oh no!&lt;/p&gt;
&lt;p&gt;Oh - actually - I just moved to this to &lt;a href=&quot;https://val.town&quot;&gt;val.town&lt;/a&gt; and built a quick server side proxy. It takes in the same arguments that&apos;s send to my client side code (lat, lng, and a timestamp), and passes it to the historical Pirate Weather endpoint. So here&apos;s the frontend code - again - this is being driven by a concurrency function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function fetchDayTemperatures(lat, lng, unixSeconds) {
  let req = await fetch(PIRATE_WEATHER_API_BASE, {
    method: &amp;quot;POST&amp;quot;,
    body: JSON.stringify({
      lat,
      lng,
      unixSeconds,
    }),
  });
  return await req.json();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here&apos;s the backend code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();
  const key = Deno.env.get(&amp;quot;PIRATE&amp;quot;);

  const url = new URL(
    `https://timemachine.pirateweather.net/forecast/${key}/${body.lat},${body.lng},${body.unixSeconds}`,
  );
  url.searchParams.set(&amp;quot;units&amp;quot;, &amp;quot;us&amp;quot;);
  url.searchParams.set(&amp;quot;exclude&amp;quot;, &amp;quot;minutely,hourly,alerts,flags&amp;quot;);

  const response = await fetch(url);
  const payload = await response.json();

  if (!response.ok) {
    throw new Error(payload.error || &amp;quot;Weather lookup failed.&amp;quot;);
  }

  const day = payload.daily?.data?.[0];
  if (!day) {
    throw new Error(&amp;quot;Weather data did not include a daily summary.&amp;quot;);
  }

  return Response.json({
    timezone: payload.timezone,
    high: day.temperatureMax,
    low: day.temperatureMin,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I barely modified this from the original client side code - only switching to an environment variable for the API (their API is free, but I might as well) and returning a proper &lt;code&gt;Response&lt;/code&gt; object.&lt;/p&gt;
&lt;p&gt;Here&apos;s a screenshot of it in action:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/weather.png&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;You can try this yourself here: &lt;a href=&quot;https://weathercomparison.val.run/&quot;&gt;https://weathercomparison.val.run/&lt;/a&gt;. If you want to see the code, and possibly fork the val, you can do so here: &lt;a href=&quot;https://www.val.town/x/raymondcamden/weather-comparison&quot;&gt;https://www.val.town/x/raymondcamden/weather-comparison&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;so%2C-was-it-cooler%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#so%2C-was-it-cooler%3F&quot;&gt;So, was it cooler?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;No. I was wrong. Last year was cooler than this year, but the three years before that were all higher, with 2022 being pure hell. I remember that year seeing the city working on a road that had literally buckled because of heat.&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@peteralbanese?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Peter Albanese&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/black-and-white-cat-on-glass-window-K9_Igf8ZpC0?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/14/send-me-a-message-in-a-panel</id>
                <title>Send me a message in a panel...</title>
                <updated>2026-05-14T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/14/send-me-a-message-in-a-panel" rel="alternate" type="text/html" title="Send me a message in a panel..."/>
                <content type="html">
				
                        &lt;p&gt;On my birthday a few weeks ago, one of things I got was something I&apos;ve wanted to play with for a while, the &lt;a href=&quot;https://divoom.com/products/pixoo-64&quot;&gt;Divoom Pixoo64&lt;/a&gt; pixel frame. This is pixel art frame you can hang on your wall and with an app, select art, clock faces, and more. It&apos;s fun, although the app itself isn&apos;t my favorite. But - what excites me is that it has an API you can use to change what&apos;s shown on the frame. I actually built a demo of this with Webflow you can see below:&lt;/p&gt;
&lt;lite-youtube videoid=&quot;oRyVxxi6ew8&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/oRyVxxi6ew8/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=oRyVxxi6ew8&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;p&gt;I was thinking about how else I could play with the API and decided to do something a bit risky - build a tool that lets you (yes, you!) send me a message right to my device. How did I do it? Let me describe the process from the bottom up.&lt;/p&gt;
&lt;h2 id=&quot;the-python-server&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-python-server&quot;&gt;The Python Server&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;At the lowest level is a Python server running on my machine. Yes, this isn&apos;t persistent and not stable, but who cares. The server handles accepting a string to display and rendering it on the Pixoo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from pixoo_ng import Pixoo, Channel
from pixoo_ng.config import PixooConfig
import time

def split_string(text: str, max_chars: int) -&amp;gt; list[str]:
    &amp;quot;&amp;quot;&amp;quot;Split a string into chunks of up to max_chars characters, without breaking words.&amp;quot;&amp;quot;&amp;quot;
    words = text.split()
    chunks = []
    current = &amp;quot;&amp;quot;

    for word in words:
        if not current:
            current = word
        elif len(current) + 1 + len(word) &amp;lt;= max_chars:
            current += &amp;quot; &amp;quot; + word
        else:
            chunks.append(current)
            current = word

    if current:
        chunks.append(current)

    return chunks

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == &apos;/favicon.ico&apos;:
            self.send_response(404)
            self.end_headers()
            return

        query = parse_qs(urlparse(self.path).query)
        input_text = (query.get(&apos;input&apos;, [&apos;&apos;])[0]).strip()

        if not input_text:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b&amp;quot;Missing &apos;input&apos; query parameter&amp;quot;)
            return

        pix = Pixoo(PixooConfig(address=&apos;192.168.0.191&apos;))

        startY = 5
        strings = split_string(input_text, 15)

        for line in strings[:8]:
            print(line, startY)
            pix.draw_text(line, (2, startY), (0, 255, 0))
            startY += 5

        pix.push()

        self.send_response(204)
        self.end_headers()
        self.wfile.flush()

        # Show it for 10 seconds...
        time.sleep(10)

        # Then go back to my regular face
        pix.set_channel(Channel.FACES)
        pix.set_face(0)

    def log_message(self, format, *args):
        print(f&amp;quot;{self.address_string()} - {format % args}&amp;quot;)


if __name__ == &amp;quot;__main__&amp;quot;:
    server = ThreadingHTTPServer((&amp;quot;&amp;quot;, 8099), Handler)
    print(&amp;quot;Listening on port 8099&amp;quot;)
    server.serve_forever()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important bits is how it handles dynamic strings. When you send text to the Pixoo, you have to handle ensuring it actually fits, so to do that, I used a function, &lt;code&gt;split_string&lt;/code&gt;, which wraps text on words into an array. I then take that array (up to 7 lines) and send each line progressively lower on the panel.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/pix2.png&quot; loading=&quot;lazy&quot; alt=&quot;Pixoo device&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;To make this little server &amp;quot;live&amp;quot;, I simply used ngrok to expose it.&lt;/p&gt;
&lt;h2 id=&quot;calling-the-server&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#calling-the-server&quot;&gt;Calling the Server&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To call the server, I set up an API route on &lt;a href=&quot;val.town&quot;&gt;https://val.town&lt;/a&gt; that does the minimal work of proxying a front end call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();

  //trim message to 80
  let msg = body.message.substring(0, 80);
  let API = Deno.env.get(&amp;quot;API&amp;quot;);
  console.log(`Sending ${msg}`);
  await fetch(API + `?input=${msg}`);

  return Response.json({ ok: true });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ngrok URL is an environment variable and as I don&apos;t care about the response, I return a basic boolean value back.&lt;/p&gt;
&lt;h2 id=&quot;the-front-end&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-front-end&quot;&gt;The Front End&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;And lastly, I used Claude to build a simple front end. It&apos;s just a form with a field and button:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/pix3.png&quot; loading=&quot;lazy&quot; alt=&quot;Pixoo device&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The JavaScript literally just takes the field and sends it to the server code above, but if you want to see the complete project, you can check it out on val.town: &lt;a href=&quot;https://www.val.town/x/raymondcamden/send-me-a-message&quot;&gt;https://www.val.town/x/raymondcamden/send-me-a-message&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Want to try it? I&apos;ll share the URL, but first...&lt;/p&gt;
&lt;h2 id=&quot;privilege&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#privilege&quot;&gt;Privilege&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I feel safe sharing this demo as my device is in my office, a room my kids don&apos;t go into, and I can shut it down in seconds. Heck, I&apos;ll probably forget it&apos;s running and next time I boot this machine, it won&apos;t run anyway. So yeah, no big deal.&lt;/p&gt;
&lt;p&gt;But also - I&apos;m a guy. Probably the worst thing I&apos;ll get is someone calling me foul names.&lt;/p&gt;
&lt;p&gt;I can&apos;t imagine there is any world where a woman would feel safe with this kind of connection to random people on the internet.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#try-it&quot;&gt;Try It&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ready to try it? I make no guarantees it will work, but then again, you&apos;ll never know. ;) Hit the form here: &lt;a href=&quot;https://raymondcamden--45cb2f404fa111f1b5dcee650bb23af1.web.val.run/&quot;&gt;https://raymondcamden--45cb2f404fa111f1b5dcee650bb23af1.web.val.run/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@jayneharr33?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Jayne Harris&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-message-in-a-bottle-sitting-on-the-beach-EDTXcRCCVGk?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="python" />
                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
</feed>