# Greg Wilson's Tech Blog — Full Corpus > Greg Wilson's tech blog — notes on AWS, AI, photography, and cars from the person who leads the AWS Documentation and SDK/CLI teams. Author: Greg Wilson Site: https://gregwilson.tech --- # Building domain-specific AI chatbots: JazzQuery, RennQuery, WristQuery, and Celiac vs Me Published: 2026-06-25 URL: https://gregwilson.tech/building-domain-specific-ai-chatbots Tags: claude, ai, serverless, chatbots, rag A technical deep-dive on the shared framework behind four niche AI companions — the serverless chat architecture, the cheap-Haiku/smart-Sonnet model split, curated web allowlists, and local knowledge bases (Wikipedia mirrors, a 33-million-row jazz discography, and my own book). I keep building the same thing: a chatbot that's a genuine expert in exactly one niche and politely useless outside it. Ask it anything in its lane and it's sharp, opinionated, and sourced. Ask it to write you a Python script or summarize the news and it declines and steers you back. There are four of them now: - **[JazzQuery](https://jazzquery.com)** — jazz: musicians, sessions, sidemen, labels, discographies. - **[RennQuery](https://rennquery.com)** — Porsche: models, generations, specs, motorsport, the people. - **[WristQuery](https://wristquery.com)** — watches: brands, references, movements, complications, collecting. - **[Celiac vs Me](https://celiacvsme.com)** — a chat companion for living with celiac disease, grounded in federal medical references *and* my own book of the same name. They look like four different products. Under the hood they're the same machine with the paint swapped. This post is that machine: the web architecture, the two-model trick that keeps the bills small, the curated-source approach that keeps the answers honest, and the per-site knowledge bases that make a general-purpose model behave like a specialist — including a local mirror of a chunk of Wikipedia, a 33-million-row jazz discography, and a full book manuscript loaded into a search index. As always, these are evening-and-weekend projects squeezed around a day job — which is only realistic because I don't write the code by hand anymore. I do the architecture, the judgment calls, and the data curation; [Claude Code](https://www.anthropic.com/claude-code) does the building. Running *four* of these without it becoming a second job — or, so far, a real expense — is the whole point of the design. (And if you want to spin up your own, there's a [build kit](/domain-chatbot-build-kit.md) at the end.) --- ## The four sites at a glance | Site | The hard part | | --- | --- | | **JazzQuery**
jazz | "Who played drums on this session, and did these three ever record together?" — answered from a local copy of the Discogs catalog, not the model's memory. | | **RennQuery**
Porsche | Decades of generations and engine codes where a confident-but-wrong answer is worse than no answer. Grounded in a local Wikipedia mirror. | | **WristQuery**
watches | Reference numbers, caliber specs, and a market where "current value" genuinely needs a live web search — but most questions don't. | | **Celiac vs Me**
celiac disease | Medical accuracy plus lived experience. The model leans on public-domain federal guidance for the facts and my book for the human side. | The differences are all in the **knowledge** layer. Everything around it — the web app, the model routing, the guardrails, the cost controls — is shared. --- ## One framework, four facades Every site is the same stack: | Layer | Why | | --- | --- | | **Client** — React 19 + Vite (SPA) | A real-time chat UI with streaming, citations, and feedback isn't a static page. Unlike [this blog](/) (zero JS), the chat apps earn their JavaScript. | | **Server** — a serverless backend that can stream | One command to deploy, runs close to users, scales to zero between requests, push-to-`main` auto-deploys. | | **Session** — a per-session stateful object | One addressable actor per browser session with its own embedded SQLite: chat history, the rate-limit log, the list of domains a crawler has blocked. | | **Analytics + knowledge** — SQLite, with FTS5 full-text search | Anonymous query/usage logs, the pre-computed answer cache, and the domain knowledge tables (the Wikipedia mirror, the discography, the book). | | **Chat plumbing** — a WebSocket router + the [Vercel AI SDK](https://sdk.vercel.ai/) | A thin router maps each session's WebSocket to its stateful object; the AI SDK's `streamText` handles the model call, tool loop, and streaming. | The request flow for a single question: ```text Browser ──WebSocket──▶ Server ──▶ Session object (one per browser) │ ├─ 1. cached answer? (zero-cost fast path) ├─ 2. rate-limited? (30/hour per session) ├─ 3. Haiku gate (on-topic? needs the web?) └─ 4. Sonnet + tools (search_wiki / discogs / web) │ Browser ◀──streamed text + citations──┘ ``` The heart of it is one `streamText` call. Simplified, but this is really the shape: ```ts const result = streamText({ model: anthropic(MAIN_MODEL), // claude-sonnet-4-6 messages: [CACHED_SYSTEM, ...history], tools: { search_wiki: searchWikiTool(db), // free, always on // attach web_search ONLY when the gate flagged the turn FRESH: ...(gate.needsSearch ? { web_search: anthropic.tools.webSearch_20260209({ maxUses: 1, allowedDomains, }) } : {}), }, stopWhen: stepCountIs(6), // bound the tool/reasoning loop onFinish: logTurnUsageAndCost, }); ``` Responses stream back token-by-token over the socket as Server-Sent-Event-style chunks, with source citations emitted as their own `source-url` parts so the UI can render them as little pills under each answer. The client paces the reveal at a steady ~220 characters/second so it reads like typing instead of bursting in jerky clumps. A nice side effect of the shared design: when I learn something on one site, all four get it. The "never narrate a lookup" rule I'll describe below started as a JazzQuery bug fix and is now in every system prompt. --- ## Two models: a cheap bouncer and a smart expert The single most important design decision is that **two different Claude models** handle every question, and the cheap one runs first. - **[Claude Haiku 4.5](https://www.anthropic.com/claude)** is the bouncer. It runs on *every* message and decides, in one word, whether the question is even in scope. - **[Claude Sonnet 4.6](https://www.anthropic.com/claude)** is the expert. It only runs once Haiku waves the question through, and it's the one with the tools, the long system prompt, and the real cost. Why bother? Cost asymmetry. A full answer costs me about two cents — and as much as fifteen once it's run a web search or two. A Haiku gate call is a fraction of a cent. Rejecting an off-topic question (or absorbing someone trying to use my jazz site as a free coding assistant) is **tens to hundreds of times cheaper** than answering it. The bouncer makes abuse boring and cheap instead of expensive. The first three sites use a simple `YES` / `NO` / `MAYBE` topic gate. WristQuery has the most evolved version, where a single Haiku call does double duty — it gates *and* decides whether the answer needs the live web: ```ts // Haiku returns exactly one word: OFF, KNOWN, or FRESH. const decision = /FRESH/.test(up) ? "FRESH" : /KNOWN/.test(up) ? "KNOWN" : "OFF"; // First turn must be KNOWN/FRESH; short follow-ups inherit the topic. const ok = isFirstUserMessage ? decision === "KNOWN" || decision === "FRESH" : decision !== "OFF"; return { ok, needsSearch: decision === "FRESH" }; ``` - `OFF` → refuse (not about watches, or it's a "write me code" request in disguise). - `KNOWN` → answer from the model's own training. **No web search tool is even attached.** - `FRESH` → needs current data (a price, availability, this year's release), so the web search tool gets bolted on for that turn only. That `needsSearch` flag is why the `web_search` tool was conditionally spread into the `tools` object in the snippet above. Most watch questions are `KNOWN` — "what's the difference between a 116610 and a 126610 Submariner" doesn't need the internet — so most turns never pay for a search at all. Sonnet's system prompt is where each site gets its personality and its limits. They all share the same skeleton — **Scope**, **Voice**, **Style**, **Tool use** — and one hard-won rule that I'll quote because it cost Claude Code and me a real debugging session to land on: > Be decisive, and NEVER narrate a lookup you are about to do… a turn that ends on a promise (text with no tool call) stops generation and strands the user with no answer. Early on, the model would sometimes end a message with "Let me look that up for you…" and then just… stop. The tool call never came, because the turn was over. To the user it looked like the bot froze mid-sentence. The fix lives in the prompt: tool calls are invisible, so never announce them, and every turn must *end* on a real answer, never on a plan to continue. A couple of other knobs keep Sonnet in line and cheap: - The whole system prompt is sent with `cacheControl: { type: "ephemeral" }`, so Anthropic's [prompt caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) bills the repeated prefix at ~10% of the normal input rate. - `stepCountIs(6)` caps the tool-use loop so a confused turn can't spiral into a dozen searches. - All calls go to the Anthropic API directly via the AI SDK. --- ## Curating the web instead of trusting all of it When one of these bots *does* search the web, it can't just search *the web*. Open web search is a hallucination delivery mechanism: SEO spam, content farms, and confidently wrong forum posts all rank, and the model happily cites them. So each site ships a hard **allowlist** of domains it's allowed to search. WristQuery's, trimmed: ```ts const SEARCH_ALLOWED_DOMAINS = [ // Reference & specs "wikipedia.org", "watchbase.com", "calibercorner.com", "ranfft.org", // Brand-official "rolex.com", "omegawatches.com", "patek.com", "audemarspiguet.com", // Editorial & journalism "hodinkee.com", "fratellowatches.com", "monochrome-watches.com", "watchtime.com", // Community & forums "watchuseek.com", "rolexforums.com", "reddit.com", // Market, values & auctions "chrono24.com", "watchcharts.com", "phillips.com", "christies.com", "sothebys.com", ]; ``` RennQuery's list is Porsche.com, Rennlist, Total 911, Bring a Trailer, Hagerty, and the automotive press. JazzQuery's is AllMusic, JazzDisco, the Smithsonian, Blue Note, ECM, DownBeat, and NPR. Celiac vs Me's is Mayo Clinic, Cleveland Clinic, NIDDK, celiac.org, Beyond Celiac, and the Gluten Free Watchdog. The list *is* the editorial judgment. The allowlist is handed straight to the search tool, which filters results before they ever reach the model: ```ts anthropic.tools.webSearch_20260209({ maxUses: 1, allowedDomains: SEARCH_ALLOWED_DOMAINS }) ``` There's one production gotcha worth sharing. Some of those domains block Anthropic's search crawler, and when that happens the whole request fails with a hard `400` rather than just skipping the site. So there's a small retry loop: catch the "these domains are blocked" error, drop the offenders, remember them in the session object's SQLite so the next turn doesn't repeat the mistake, and retry — invisibly to the user. If every domain ends up blocked, it falls back to an unrestricted search rather than breaking. --- ## Mirroring Wikipedia locally to skip the toll Here's the realization that shaped RennQuery and WristQuery: for a domain like Porsche history or horology, **most of the good factual content lives in a few hundred Wikipedia articles that almost never change.** Paying for a metered web search every time someone asks about the 997.2's engine, when the answer is sitting in the same Wikipedia article it was in last month, is silly. So I have Claude Code copy those articles into a local table for me. The build script it wrote (`fetch-wiki-corpus.mjs`) walks a curated set of seed titles and category trees — skipping noise like clocks and smartwatches — pulls the article text, chunks it, and writes a SQL file that gets loaded into the database. The schema is a [SQLite FTS5](https://www.sqlite.org/fts5.html) full-text index: ```sql CREATE TABLE wiki_articles (page_id INTEGER PRIMARY KEY, title TEXT, url TEXT, fetched_at INTEGER); CREATE VIRTUAL TABLE wiki_fts USING fts5( title, section, content, url UNINDEXED, tokenize = 'unicode61 remove_diacritics 2' ); ``` The model gets a `search_wiki` tool backed by it. FTS5's `MATCH` syntax chokes on punctuation like `997.1`, so the query is reduced to bare word tokens, OR-ed together, and ranked by [BM25](https://en.wikipedia.org/wiki/Okapi_BM25): ```ts const { results } = await db .prepare(`SELECT title, section, url, content, rank FROM wiki_fts WHERE wiki_fts MATCH ? ORDER BY rank LIMIT ?`) .bind(buildFtsQuery(query), WIKI_RESULT_LIMIT) // e.g. "997" OR "1" OR "turbo" .all(); ``` The economics are the whole point. A `search_wiki` hit costs only the tokens it returns — **no per-search fee and no 10,000-token payload of raw web results**. So the system prompt steers the model here *first* for anything historical or spec-related, and reserves the paid web search for things that genuinely change: prices, availability, this year's news. If copying a slice of Wikipedia onto my own server sounds legally sketchy — it isn't. Wikipedia's text is published under the [Creative Commons Attribution-ShareAlike license](https://en.wikipedia.org/wiki/Wikipedia:Reusing_Wikipedia_content) (CC BY-SA), which explicitly permits reuse (commercial use included) on two conditions: **attribution** (credit Wikipedia and link back to the article) and **share-alike** (if you redistribute an adapted version, you license it under the same terms). The attribution is built in: every chunk the model retrieves carries its source URL and gets cited in the answer, exactly like a web-search result. (One subtlety: because every returned chunk becomes a citation, a weak query that only matched common words like "the" or "911" would attach irrelevant articles to the answer. So results scoring worse than half the top hit's BM25 rank get dropped before they become citations.) --- ## JazzQuery's 33-million-row discography Wikipedia mirroring works when the knowledge is prose. Jazz isn't prose — it's a *database*. "Who played tenor on this date," "did McCoy Tyner and Elvin Jones ever record outside Coltrane's group," "what's the personnel on this album" are structured queries, and asking a language model to answer them from memory is how you get confident, beautifully worded, completely invented sidemen. So JazzQuery doesn't ask the model to remember. It ships a copy of the [Discogs](https://www.discogs.com/) catalog. Discogs publishes a monthly data dump of its entire database. An ETL pipeline filters it down to jazz, normalizes it, and loads it into a read-only SQLite database — **about 33 million rows, ~1.6 GB**: artists, their aliases, labels, masters (the canonical "work"), releases (specific pressings), per-track listings, and the payload table, `credits` — the "who played what on which release" edge list. ```sql CREATE TABLE credits ( release_id INTEGER NOT NULL, -- -> releases.id artist_id INTEGER NOT NULL, -- -> artists.id role TEXT NOT NULL, -- raw: 'Saxophone [Tenor]' instrument TEXT, -- normalized: 'saxophone' track_pos TEXT -- per-track scope, or NULL = whole release ); ``` "Jazz" turned out to be the interesting filtering problem. A genre tag alone misses too much, so inclusion happens in three layers, recorded in each master's `inclusion` column (`'genre' | 'style' | 'artist'`): records tagged Jazz; records in adjacent jazz *styles*; and — the fun one — **records that aren't jazz at all but credit a jazz musician as a player.** That last layer is why JazzQuery knows Steve Gadd's session work shows up on Paul Simon and Steely Dan records, not just on jazz albums. Each artist also carries a `jazz_score` (how many jazz credits they have), which both drives that expansion and disambiguates names. Ask about "Steve Gadd" and you get the drummer, not the score-zero namesake. The model reaches the database through a `discogs_lookup` tool with five query shapes — `artist`, `discography`, `personnel`, `album`, and `collaboration`. The collaboration one is my favorite, because it answers "did all of these people record together" as a single set intersection instead of making the model cross-reference three separate discographies in its head: ```ts // Did N artists all appear on the same release? One credit intersection. const where = ids.map(() => "r.id IN (SELECT release_id FROM credits WHERE artist_id = ?)" ).join(" AND "); const shared = await rows(db, `SELECT DISTINCT m.title, m.year FROM masters m JOIN releases r ON r.master_id = m.id WHERE ${where} ORDER BY m.year LIMIT 40`, ...ids); ``` Every result links back to the real Discogs page it came from, so the citations are verifiable. And the system prompt is strict: for personnel and "did they record together" questions, the model **must** call the tool and answer from it — never from memory. Two production details I'm a little proud of — both of which took some back-and-forth with Claude Code to get right: - **Read replicas.** A 1.6 GB database has real query latency if every read crosses regions, so the queries run against an in-region read replica with relaxed consistency — safe, because this data only changes once a month. - **Predictive warm-up.** The managed database evicts an idle instance, and the cold-start can be 15–20 seconds — brutal in a chat. Claude Code's first instinct was a cron job pinging the database every few minutes to keep it warm; that works, but it felt kludgy and wasteful to me — paying to poke a database nobody's using. The idea I landed on instead: when you *start typing*, the client quietly fires a request that wakes the replica and warms its caches. By the time you hit send, the database is hot, and the cold start has hidden itself behind your typing. (Loading 33 million rows into a managed SQLite store had its own war stories — Claude Code and I learned the hard way that building an index on a multi-million-row table runs it out of memory, so the loader creates every index on the empty table *first*, then bulk-inserts. But that's a post of its own.) --- ## Celiac vs Me: putting my own book in the model's hands Two years ago, I was diagnosed with celiac disease and had to learn to navigate a world where I can't consume even the tiniest amount of gluten. I wrote a book about what I learned and how the transition felt — so for this one, I wanted to bring that knowledge and experience into the chat's answers. The celiac companion is the most personal of the four, and it has the most interesting knowledge problem, because the right answer depends on what *kind* of question it is. "What's the FDA definition of gluten-free?" is a settled fact with an authoritative source. "How do I get through my first Thanksgiving after diagnosis without feeling like a burden?" is lived experience. The first should never need a web search; the second isn't in any medical reference at all. So the model has a three-tier sourcing order, and the system prompt tells it which to reach for: 1. **`search_reference`** — public-domain federal material: the NIDDK's celiac pages and the FDA's gluten-free labeling rule. Government works aren't copyrighted, so I ingested them into an FTS5 table (32 chunks). A hit here is sub-penny and authoritative — far better than paying to web-search a fact that's been settled since 2013. 2. **`search_book`** — the full manuscript of my book, *Celiac vs Me: When Gluten Turns Your Food World Upside Down*, loaded into its own FTS5 index. This is where the lived-experience answers come from. 3. **Web search** — only for things that actually change: recalls, current products, new research, restricted to the curated medical allowlist. Getting the book into the model is a little pipeline of its own — another Claude Code build. The manuscript is a Word document; `ingest-book.mjs` runs it through [mammoth](https://github.com/mwilliamson/mammoth.js) with a style map (so the book's custom Word heading styles become real `h1`/`h2` markers), splits it into chapters and sections, and packs the paragraphs into ~1,400-character chunks, splitting oversized ones at sentence boundaries. The result is loaded into: ```sql CREATE VIRTUAL TABLE book_chunks USING fts5( content, chapter UNINDEXED, section UNINDEXED, ord UNINDEXED ); ``` The `search_book` tool runs a plain BM25 match and returns up to six passages, each labeled with its chapter: ```ts const rows = await db.prepare( `SELECT chapter, section, content FROM book_chunks WHERE book_chunks MATCH ? ORDER BY bm25(book_chunks) LIMIT 6` ).bind(terms.join(" OR ")).all(); ``` Because every passage knows its chapter, the model can say "the book digs into this in *The Table*" and the UI links the citation to that chapter's excerpt page on the site. The chapters even have names that map to the parts of life celiac disease upends — *The Diagnosis*, *The Aisles*, *The Table*, *The Road*, *The Calendar*, *The Body Underneath*, *The Emotional Journey* — so the citations read like a table of contents. Those excerpt pages are part of a bigger whole. celiacvsme.com isn't only the chatbot — it's also a static companion site to the book (an excerpt from every chapter, plus a plain-Markdown mirror of each page) and a gluten-free-living blog, built the same agent-friendly way as this site: structured data and an [`llms.txt`](https://celiacvsme.com/llms.txt) index included. Claude Code built all of it from my manuscript, which saved me an enormous amount of time — and is why the chat's citations land on real chapter pages instead of dead ends. Two deliberate choices worth calling out: - **It's keyword search, not embeddings.** No vector database, no embedding model, no similarity threshold to tune — just SQLite's full-text index, which the database engine already provides for free. For a single book and a handful of reference pages, BM25 is plenty, and the whole thing is one `MATCH` query. Reaching for a vector store here would have been resume-driven development. - **The manuscript never touches the repo.** The ingest script writes a SQL file that's gitignored and loaded out-of-band. The book's text lives in the database the bot queries, not in source control. One thing newly diagnosed people often miss is that *gluten-free* and *celiac-safe* aren't the same thing. A product can carry a gluten-free label and still be handled or cross-contaminated in ways that make it unsafe for someone with celiac disease. That gap matters enough that I wrote a bit of code for it: when a question is about something *gluten-free*, the prompt gets quietly augmented to pull in *celiac-safe* too, so the answer draws the distinction instead of letting the reader assume the two are interchangeable. And because it's a medical topic, the system prompt carries a safety layer the other three don't: it's explicit that this isn't medical advice, never tells anyone to start or stop a medication, and nudges people to a clinician when a question crosses into diagnosis or treatment. --- ## The boring machinery that keeps it cheap and honest A few cross-cutting pieces show up in all four and are most of the reason I can afford to run them: | Mechanism | What it does | | --- | --- | | **Pre-wired answer cache** | The suggested starter questions on each home page are pre-computed and stored in the database. Clicking one streams a cached answer at **zero model cost** — no gate, no search, no Sonnet. It's even paced to mimic live typing so you can't tell. | | **Staggered weekly refresh** | A cron job regenerates those cached answers weekly. JazzQuery refreshes at 09:00 UTC Mondays, Celiac at 10:00 — deliberately an hour apart, because they share one Anthropic organization and the web-search tool is rate-limited org-wide. (A scaling lesson learned the slightly hard way.) | | **History pruning** | Before each turn, the bulky tool results and citations from *previous* turns are stripped out of the history sent to the model — saving 10,000–15,000 stale tokens on every follow-up. | | **Rate limits** | 30 questions/hour per session, plus a 60-requests-per-minute-per-IP cap, so no single visitor can run up a bill. | | **Bot protection** | A bot-challenge widget (a CAPTCHA-style check) mints a signed, HttpOnly session cookie; every chat request checks the signature before spending a cent on a model. | None of it stores anything personal — the query and usage logs are anonymous and carry only a coarse, IP-derived city, never the IP itself. --- ## Paying attention to the cost I built these for fun, and monetizing never crossed my mind. Then I had Claude Code log the cost of every query and tally it up — and that number got my attention. Even with all the cost engineering above, none of it makes a query *free*. You saw the math a few sections back: a couple of cents for a typical answer, up to fifteen once it's had to hit the web. That's nothing when it's me and a few friends kicking the tires. It is emphatically *not* nothing if one of these ever catches on. Ten thousand questions in a month (not a big number, for anything with traction) at a nickel apiece is $500, for a site that earns exactly zero. So for now I've done the honest hobbyist thing: set aside a little money, left the doors open, and let people play. No login, no paywall — just the rate limits above, so no single visitor can run up the tab. I also added a tip jar, a few Stripe payment links, though I hold approximately zero illusions about tips covering the bill. It's a "if this saved you a search-engine rabbit hole, buy me a coffee" gesture, not a business model. If any of them genuinely takes off, the honest answer is a subscription gate, and the email-gating plan in *What's next* is the first brick in that wall: a handful of free questions to get someone hooked, then a sign-in before a curious visitor quietly turns into a line item. Everything earlier in this post — the cheap gate, the curated search, the local knowledge bases — exists to push that day as far out as possible. Good architecture buys runway, not immunity. --- ## Hosting this Nothing above is really tied to one cloud. Strip the brand names and the app is a handful of generic capabilities: a serverless backend that can stream, a scrap of per-session state, a SQL database with full-text search, static hosting, a bot challenge, and a weekly cron. All three big providers can run every piece — here's the mapping: | Capability | AWS | Google Cloud | Cloudflare | | --- | --- | --- | --- | | Compute + streaming/WebSocket | API Gateway WebSocket + Lambda, or Fargate / App Runner | Cloud Run | Workers | | Per-session state | DynamoDB, keyed by session | Firestore (or Memorystore) | Durable Objects | | SQL + full-text search | Aurora Serverless v2 (Postgres `tsvector`), or OpenSearch for BM25 | Cloud SQL / AlloyDB (Postgres) | D1 (SQLite + FTS5) | | Static assets + CDN | S3 + CloudFront | Cloud Storage + Cloud CDN | Static Assets | | Bot challenge | AWS WAF CAPTCHA | reCAPTCHA Enterprise | Turnstile | | Scheduled cache refresh | EventBridge Scheduler → Lambda | Cloud Scheduler → Cloud Run | Cron Triggers | One row is more interesting than the rest: **the per-session state.** The clean version of this design wants a single addressable object per chat session — one place to hold that user's history, rate-limit counter, and blocked-domain list without hitting a shared database on every keystroke. One of the three hands you that primitive directly (Durable Objects); on the other two you build it yourself, from stateless compute plus a managed store keyed by the session id. The first is less code; the second is more portable. Pick your trade. For the rest, the closest *spiritual* port is "a small container that holds the streaming connection and talks to a database" — a long-lived service like Cloud Run, Fargate, or App Runner — rather than the fully serverless, one-invocation-per-message path. Both work; the container is just fewer moving parts when the entire job is to keep a socket open and stream tokens down it. Two things stay constant wherever it runs. The knowledge bases are nothing more than **SQLite plus full-text search**, and every one of these platforms has a stand-in (Postgres' built-in full-text search is the usual one). And Claude is reachable from all of them — the Anthropic API directly, or through Amazon Bedrock or Google Vertex AI — so "which cloud" and "which model provider" stay independent choices. --- ## How I built this with Claude Code I should be clear about who wrote what. I designed the architecture, made the judgment calls, and curated every source — but I didn't write the code. [Claude Code](https://www.anthropic.com/claude-code) did. My job was to know *what* to build and to catch it when it was wrong; the typing was its problem. That division of labor is the only reason someone with an evening or two a week can ship and run four of these. Here's roughly how I drove it — the order each site came together in: 1. **Scaffold the chat shell.** The first prompt describes the architecture — a streaming chat UI, a serverless backend, a per-session object — and nothing domain-specific. Claude Code wires the boilerplate I never want to write again: the socket routing, the token streaming, the session plumbing. By the end of the first sitting you have a working (if clueless) chatbot. This is where you pick your stack; the agent fills it in. 2. **Add the two-model gate.** Then make it cheap: a small model classifies every message before the strong one answers. This is also where Claude Code's first draft taught me something — its initial system prompt let the model say "let me look that up…" and then stop, stranding the user. That's the bug behind the "never narrate a lookup" rule above. I described the failure; it found the fix. Expect that loop constantly: it writes, you watch it break on a real input, you describe what's wrong, it corrects. 3. **Build the knowledge base.** The domain-specific heart, and where *your* work matters most. You decide the shape — a prose corpus, a structured database, a book — and you curate the sources; Claude Code writes the ingest script and the search tool. The fiddly production lessons surfaced right here (the blocked-domain retry, creating indexes *before* the big bulk-load), and they came out of the two of us staring at failures together, not from me knowing them up front. 4. **Guardrails and cost controls.** Rate limits, the bot challenge, the answer cache, history pruning. Mostly mechanical: state the policy, let it implement. The predictive warm-up trick was just me noticing a cold-start in testing and asking, "this is slow on the first query — can we hide it behind the user typing?" 5. **Verify and harden.** Where a human still earns their keep — though I had more help than I expected. When a response came back wrong, or just hung, I didn't go log-diving myself; I told Claude Code, and it would tail the logs, fire test queries at the backend, and iterate until it cornered the cause. My job was the judgment: read the diffs, throw ugly and abusive inputs at the gate, and watch the bill. It writes *and* debugs faster than you can read — knowing what *correct* and *cheap* look like is the part that isn't automated yet. The part that surprised me most came at the end. The unglamorous polish — accessibility, SEO, the newer answer-engine optimization (being citable by AI), Open Graph cards, favicons, raw performance — used to be the most tedious, never-finished work in any project. There was always one more thing to remember. So I had Claude Code audit every site top to bottom, recommend fixes, and implement them. Where Google's PageSpeed Insights came back in the high 80s and low 90s, I just pasted in a screenshot; it diagnosed the cause, fixed it, and re-tested. They load fast and score a clean 100 across the board now. That same fluency made the cosmetic work cheap, too: I could try on a dozen color schemes and layouts across the sites in an afternoon, where each change used to cost a weekend. That's the whole shape of it: I never touched the socket plumbing or the SQL, and never wanted to. I spent my time on the parts a model can't do for you yet — choosing the architecture, curating the sources, and judging whether the answer was actually right. **Want to build your own?** I packaged all of this into a starter kit you can hand straight to Claude Code — the architecture, a paste-in starter prompt, a `CLAUDE.md` template, and the five phases above, all host-neutral: **→ [Download the build kit](/domain-chatbot-build-kit.md)** — or point Claude Code at the URL and tell it your domain. --- ## What's next A couple of things are designed but not yet shipped. There's a worked-out plan for **email gating** across all of them — a few free questions, then a one-time email verification before an anonymous visitor can become a real cost — and, for RennQuery specifically, a design for pulling **Wikimedia Commons photos** of car variants into answers. Both are in the "researched, not built" pile, which is where evening projects live. But the real takeaway is that the pattern is *repeatable*. The first one took real effort. The fourth was mostly: pick a domain, write a new system prompt, curate the sources, decide what the knowledge base should be — and let Claude Code rebuild the rest. A general-purpose model plus a small amount of carefully curated local knowledge makes a surprisingly convincing specialist — and doing the curation yourself is what keeps it from making things up. And none of this is really about jazz or watches. The architecture is domain-agnostic; the knowledge base is the only part that changes. Point the same machine at a product's docs and support tickets and it's a support bot that actually knows your software. At a law firm's contracts and case history, it's a research assistant. At a retailer's inventory database, a concierge that knows what's in stock. The same recipe works over a catalog of song lyrics, the full canon of a book or TV series, a library of meeting or podcast transcripts, a parts catalog and its spec sheets. The fun version is a jazz nerd in a box — but the same machine sits under a lot of problems that aren't fun at all, and are worth real money. If you want to poke at them: [JazzQuery](https://jazzquery.com), [RennQuery](https://rennquery.com), [WristQuery](https://wristquery.com), and [Celiac vs Me](https://celiacvsme.com). They're all on my [things I've built](/apps) page, along with everything else. The pattern's so repeatable now that the only thing stopping a fifth is remembering who pays for the first four. --- # Every known way to reboot or reset a 2025 Volvo EX90 Published: 2025-05-27 URL: https://gregwilson.tech/reboot-volvo-ex90 Tags: ex90, volvo, volvo-ex90-cheat-sheet Every known way to reboot or reset a 2025 Volvo EX90 - The EX90 Survival Guide > **Update (May 2026):** Volvo has shipped substantial software improvements since this post was first written. The current EX90 software release is **2.1.26**, alongside a 2026 model-year hardware refresh that moves the car to an 800V architecture and a more powerful core computer (MY2025 owners are eligible to get the upgraded computer installed at a service visit). **Many of the reboots and resets below are no longer needed on current software** — in particular, the UWB digital-key reset is reported to auto-run in 1.13.17 and later, and the central-computer and infotainment reboots are needed far less often. I’m leaving the procedures here for owners still on older firmware, and as a reference if something does go sideways. If your car is on a recent build, try a normal “park, exit, walk away, come back” cycle before reaching for any of these. The 2025 Volvo EX90 is a brand new platform, and like any new platform, there are some issues. I’ve collected all of the ways that various systems can be rebooted or reset. When something goes wrong—infotainment glitches, key errors, faults, or connectivity problems—a reset or reboot often solves it. Below is a list of everything I’ve learned so far. This version of the cheat sheet covers software versions **1.2.15 through 2.1.26**. **Download the print-ready cheat sheet:** [The Volvo EX90 Cheat Sheet — 1.2.15–2.1.26 (PDF)](/images/inline/reboot-volvo-ex90/volvo-ex90-cheat-sheet.pdf). It’s optimized to print on a single page. * * *
✓ Settings retained⚠ Erases data⏱ Time required◷ Version / limit
1
🔄

Reboot Central Computer

✓ Settings retained⏱ 3–5 min◷ Once per 24 h

Fixes most faults including Charging Faults, Pilot Assist Faults, driver-display errors and most other things.

  1. Park the vehicle and keep it on (Ready mode) — no charger connected.
  2. Press and hold the brake pedal — keep holding until the last step.
  3. At the same time, press and hold both:
    • Minus button — left side of the steering wheel
    • Vol ▲ Volume Up — right side of the steering wheel
  4. Keep holding until you see “System Communication Error / Fault” on the instrument panel (10–20 seconds).
  5. Now press and hold the PARK button (end of the gear selector) for 2 seconds to complete the reset. There’s no immediate feedback (screens may dim slightly), but things reset after 30–60 seconds.
ℹ️
The car will show many errors as it comes back online. After it settles you may still see a few indicators — don’t panic. Get out, walk away (lets the car power down), come back in a few minutes and you’re good to go.
💡
New tip: Reboot the Displays / Infotainment right after this. The two combined fix most issues.
2
🗺️

Restore Missing Google Maps & Assistant

✓ Data & keys retained⏱ ~1 min

Fixes Google Maps and Google Assistant disappearing — maps not working or Google not responding to voice commands because the apps are missing.

  1. Open SettingsSystemApplicationsShow all apps
  2. Scroll to Google Maps, tap it, then Enable it. Repeat for the Google Assistant app.
  3. Open both apps — everything should work again.
3
🚦

Fix Missing Speed-Limit Indicator

✓ Settings retained⏱ ~2 min

If the speed limit has stopped showing on the driver’s display, this brings it back.

  1. Open SettingsSystemApplicationsShow all apps
  2. Scroll to Google Maps, tap it, then Storage & cache.
  3. Tap Clear storage and Clear cache. You’ll likely have to re-login to Google Maps.
ℹ️
I’ve yet to experience this problem since upgrading to 1.3.18.
4
📺

Reboot Displays / Infotainment

✓ Data & keys retained⏱ 1–2 min

Fixes excessive screen lag, misbehaving apps, frozen screens and more.

  1. Make sure the car is parked and not charging.
  2. Press and hold the Play / Pause button until the screens go black (approx. 45 seconds).
  3. When the displays return, everything is back to normal in about a minute.
  4. If issues remain, reboot the Central Computer and try again.
5
🔑

Reset the Ultra-Wideband (UWB) Digital Key Module

◷ 1.2.15 only✓ Data & keys retained⏱ ~20 min

Fixes most digital-key issues — can’t add digital keys, or your phone no longer unlocks the car.

  1. Unlock the car and exit — don’t lock it. Stand outside by the driver’s door.
  2. If you use your phone as a digital key, turn off Bluetooth or move your phone away from the car. Move any key tags away too.
  3. Tap your finger on the indent on the driver’s door handle and hold for 1 second, then lift your finger.
  4. Repeat the previous step four more times (5 taps total, one second each).
  5. The car won’t react at all — it feels useless and silly, but it works!
  6. Walk away and leave it untouched for 20 minutes. Don’t interact with the vehicle — no door handles, no Volvo app, and no key fobs nearby.
  7. When you come back, digital-key functionality should be working again.
ℹ️
This doesn’t appear to be necessary on 1.3.18 and later.
6
🧨

Full Factory Reset (Erase All Data)

⚠ Erases everything◷ Owner profile only⏱ 5–10 min + setup

Last-resort option that can fix corrupt profiles and other issues.

⚠️
This erases all settings, apps, digital keys, profiles, Bluetooth connections, etc. Only the owner profile can perform it. Plan for extra time to re-install apps and set up profiles afterward.
  1. Tap the car icon at the bottom of the screen.
  2. Go to SettingsSystemReset optionsErase all data (factory reset)
  3. Follow the on-screen instructions.
7
🐶

Leaving People or Pets in the Car (Dog Mode)

✓ Settings retained⏱ < 1 min

If you (the driver) leave the car with people or pets inside and lock it, the internal motion sensors will trigger the alarm, and the climate turns off when you leave. Override both:

  1. Turn off motion sensors: SettingsControlsscroll to Lockingturn on Occupant locking
  2. Keep climate active: tap the fan (bottom of screen)Timers (top of screen)Keep climate active
* * * Some resets (like the factory reset) require being logged in as the vehicle’s owner. There is a good [Reddit thread](https://www.reddit.com/r/VolvoEX90/comments/1jcelg1/restarting_various_systems_in_the_ex90/#:~:text=Ultra%20Wideband%20%28UWB%29%20Module,for%20the%20module%20to%20reset.) on resetting things in the Volvo EX90. If you learn of any other ways to reboot/reset, please comment below so I can keep this up to date. *Disclaimer: This information is provided for educational purposes only. The author assumes no responsibility for any damage, malfunction, or warranty voidance that may result from following these procedures. Use at your own risk. Let’s hope that the next software update makes this cheat sheet obsolete!* [#ex90](/tag/ex90)[#volvo](/tag/volvo)[#volvo-ex90-cheat-sheet](/tag/volvo-ex90-cheat-sheet) --- # The Acceleration Obsession with Cars Published: 2025-04-21 URL: https://gregwilson.tech/the-acceleration-obsession-with-cars Tags: mcmurtry, speirling, cars, sports-cars, acceleration I feel like we live in an age of automotive craziness. When I was a teenager, any car that could accelerate from 0 to 60 in under 6 seconds was considered extremely “sporty” (and out of reach). We’ve come a long ways — I recently had the opportunity ... I feel like we live in an age of automotive craziness. When I was a teenager, any car that could accelerate from 0 to 60 in under 6 seconds was considered extremely “sporty” (and out of reach). We’ve come a long ways — I recently had the opportunity to drive a car that could accelerate from 0 to 60 mph in just 2.3 seconds, and wow—it was both exhilarating and bit unsettling. Many of my passengers would become physically ill when I demonstrated this capability. It got me thinking: what are the practical limits of acceleration in production cars, and are we approaching them? ## The Human Experience of Extreme Acceleration I’ve always been drawn to cars, roller coasters, and airplanes that can accelerate quickly. The most intense acceleration I've ever experienced was 3.2g in a Decathlon airplane doing a loop during an aerobatic lesson, and it felt like my absolute upper limit. At that point, I could feel the blood struggling to reach my brain, my vision starting to narrow, and my chest compressed by an invisible elephant. To put this in perspective, when a car accelerates from 0-60 mph in 2.3 seconds, you're experiencing around 1.2g of force—enough to make most passengers uncomfortable. At 2.0 seconds, you're approaching 1.4g. Professional drivers can handle this repeatedly, but for the average person, it's an intense experience that the body isn't accustomed to. ## The Physical Limits: Tires, Traction, and Frames The acceleration we're seeing in modern hypercars isn't just challenging for human bodies—it's pushing the limits of mechanical engineering in several ways: ### Tire Traction The fundamental limit for acceleration is ultimately traction. Modern performance cars produce enough power that they could easily spin their wheels without moving forward if not for advanced traction control systems. Even with the stickiest street-legal compounds, conventional tires on pavement have a coefficient of friction that maxes out around 1.5. This means that without additional downforce, the theoretical limit for acceleration would be around 1.5g or roughly 1.8 seconds from 0-60 mph. ### Vehicle Structure At extreme acceleration levels, the forces exerted on a car's frame are enormous. Every component experiences stress as the vehicle launches forward. For example, drivetrain mounts must withstand thousands of pounds of force without flexing. This is why hypercars often feature carbon fiber monocoques and intricate internal structures—they need to maintain rigidity under these extreme conditions. ## The McMurtry Spéirling: Defying Conventional Limits The most remarkable advancement in acceleration technology today comes from a small British company called [McMurtry Automotive](https://mcmurtry.com/). Their Spéirling hypercar has shattered acceleration records with its innovative approach. ![McMurtry Spéirling side view](/images/inline/the-acceleration-obsession-with-cars/55380451-a80a-469d-9c07-aa3a55186357.webp) According to McMurtry, the Spéirling can zoom from 0-to-60 mph in just **1.4 seconds** and cover a quarter-mile in 7.97 seconds. This beats previous production vehicle records by significant margins. How is this possible? The key innovation is the car's "Downforce-on-Demand" fan system: The McMurtry Spéirling's unique performance differentiator is its fan-powered downforce system, producing 2,000 kg (4,400 lb) of downforce at a standstill. This incredible technology means the car has massive grip before it even starts moving. Behind the cockpit are two fans that spin up to 23,000rpm to suck air out from underneath the car, creating more than two tonnes of downforce from standstill—enough so it could theoretically stick to the ceiling with the fans at full speed. And in fact, it recently did exactly that! In April 2025, McMurtry demonstrated this capability by having the Spéirling drive upside down. The car drove up a ramp onto a metal platform that then rotated 180 degrees, with the fans keeping the car firmly attached to the surface. This downforce-on-demand system fundamentally changes the acceleration equation. With over two tons of downforce pushing the car into the ground, the effective traction far exceeds what would be possible with just the car's weight and conventional tires. I have to admit, when I saw this video, I immediately wondered what would happen if you made the fans blow the other way! :-) ## Other Acceleration Monsters While the McMurtry Spéirling currently holds the crown, several other production cars offer mind-bending acceleration: 1. **Rimac Nevera**: 0-60 mph in 1.9 seconds 2. **Tesla Model S Plaid**: 0-60 mph in 1.99 seconds 3. **Bugatti Chiron Super Sport**: 0-60 mph in 2.3 seconds 4. **Pininfarina Battista**: 0-60 mph in 1.79 seconds 5. **Aspark Owl**: 0-60 mph in 1.69 seconds What's notable is that most of these ultra-quick cars are electric. The instant torque delivery of electric motors gives them a significant advantage for off-the-line acceleration. Marques Brownlee got to drive the Rimac Nevera last year. It’s a must watch. He accelerates at the 3 minute mark. ## A Historical Perspective: The Evolution of Acceleration To appreciate how far we've come, let's look back at the acceleration kings of previous decades: ### 1970s The 1970s were the tail end of the original muscle car era, but emissions regulations and the oil crisis had begun to strangle performance. The fastest accelerating cars of this era included the Ferrari 512 BB and the Lamborghini Miura P400SV, both capable of impressive speeds for their time. The quickest American muscle cars of the early 70s, like the Hemi 'Cuda and Chevelle SS 454, could manage 0-60 times in the 5.5-6.5 second range—fast for the time but pedestrian by today's standards. ### 1980s During the 1980s, the Audi Sport quattro S1 E2 topped the charts with a 0-62 mph acceleration of 3.1 seconds, followed by the Ford RS200 Evolution at 3.8 seconds, and the legendary Porsche 959 at 3.9 seconds. ### 1990s The 1990s saw significant improvements with the McLaren F1, Bugatti EB110 S, and Jaguar XJR-15 all achieving 0-62 mph in 3.2 seconds. This decade marked the beginning of the modern hypercar era, with cars designed specifically to push the boundaries of performance. ### 2000s The 2000s introduced the first production cars capable of breaking the 3-second barrier. The Bugatti Veyron 16.4 led the pack with a 0-62 mph time of 2.5 seconds, followed by the Bugatti Veyron Grand Sport and Ultima GTR720 at 2.7 seconds. Each decade has seen roughly a half-second improvement in the acceleration benchmark. But the 2020s have brought a quantum leap, with multiple cars now achieving sub-2-second times—a milestone that seemed physically impossible just a few years ago. ## Airplane Acceleration: When Cars Just Can't Compare While modern hypercars are impressively quick, they're still no match for aircraft when it comes to raw acceleration: Calculations suggest a fighter jet like the F-22 Raptor with its 70,000 pounds of thrust propelling a 55,000-pound aircraft would achieve 0-60 mph in approximately 2.15 seconds—comparable to the quickest cars. But that's where the comparison ends. Aircraft continue accelerating long after cars have reached their limits. Even more impressive is the acceleration experienced on an aircraft carrier: A carrier catapult can accelerate a 35,000 kg aircraft to 60 mph in just 0.796 seconds, subjecting pilots to forces so extreme they need special hand rests to avoid knocking themselves unconscious during launch. Yes, there are special hand rests that the pilots need to grab during a carrier launch, because there is a danger that they knock themselves out if they don't have their hands holding on to something. ## The Practical Limits So what are the practical limits of acceleration in production cars? With conventional technology (wheels on pavement), we're probably approaching them at around 1.5-1.7 seconds for 0-60 mph. The laws of physics impose hard constraints on what's possible without additional tricks like downforce-generating fans. But the more relevant question might be: what are the practical limits for humans? Most people find acceleration beyond 1g to be uncomfortable and disorienting. The sensation of blood rushing from your head, your organs compressing, and your body being pinned to the seat is not something most of us want to experience on our daily commute. For now, cars like the McMurtry Spéirling remain fascinating engineering exercises and demonstration platforms for technologies that might eventually filter down to more conventional vehicles. They show us what's possible when talented engineers refuse to accept conventional limitations. ### Sources used: 1. McMurtry Automotive's official website about the Spéirling Pure electric fan hypercar: - [https://mcmurtry.com/speirling-pure/](https://mcmurtry.com/speirling-pure/) 2. Robb Report article about the McMurtry Spéirling becoming the world's fastest production car: - [https://robbreport.com/motors/cars/mcmurtry-speirling-beat-rimac-neveras-acceleration-records-1234787425/](https://robbreport.com/motors/cars/mcmurtry-speirling-beat-rimac-neveras-acceleration-records-1234787425/) 3. McMurtry Automotive's article about the Spéirling PURE track car: - [https://mcmurtry.com/the-mcmurtry-speirling-pure-a-record-breaking-track-only-fan-car-you-can-own/](https://mcmurtry.com/the-mcmurtry-speirling-pure-a-record-breaking-track-only-fan-car-you-can-own/) 4. McMurtry Spéirling Wikipedia page: - [https://en.wikipedia.org/wiki/McMurtry\_Sp%C3%A9irling](https://en.wikipedia.org/wiki/McMurtry_Sp%C3%A9irling) 5. Jalopnik article about the McMurtry Spéirling driving upside down: - [https://www.jalopnik.com/1832203/mcmurty-speirling-drives-upside-down-downforce/](https://www.jalopnik.com/1832203/mcmurty-speirling-drives-upside-down-downforce/) 6. Carwow review of the McMurtry Spéirling: - [https://www.carwow.co.uk/news/6544/mcmurtry-speirling-review](https://www.carwow.co.uk/news/6544/mcmurtry-speirling-review) 7. Top Gear article about the McMurtry Spéirling driving upside down: - [https://www.topgear.com/car-news/electric/mcmurtry-speirling-first-car-world-drive-upside-down](https://www.topgear.com/car-news/electric/mcmurtry-speirling-first-car-world-drive-upside-down) 8. McMurtry Automotive's press release about the upside-down driving world first: - [https://mcmurtry.com/upside-down-driving/](https://mcmurtry.com/upside-down-driving/) 9. encyCARpedia data about fastest accelerating cars of the 1980s: - [https://www.encycarpedia.com/top/best-accelerating-1980s-cars](https://www.encycarpedia.com/top/best-accelerating-1980s-cars) 10. encyCARpedia data about fastest accelerating cars of the 1990s: - [https://www.encycarpedia.com/top/top-accelerating-1990s-cars](https://www.encycarpedia.com/top/top-accelerating-1990s-cars) 11. encyCARpedia data about fastest accelerating cars of the 2000s: - [https://www.encycarpedia.com/top/best-accelerating-2000s-cars](https://www.encycarpedia.com/top/best-accelerating-2000s-cars) 12. Information about the fastest cars from the 1970s: - [https://www.encycarpedia.com/us/top/fastest-1970s-cars](https://www.encycarpedia.com/us/top/fastest-1970s-cars) 13. TopSpeed article about fastest accelerating reliable used cars from the 1980s: - [https://www.topspeed.com/fastest-accelerating-reliable-used-cars-from-the-1980s/](https://www.topspeed.com/fastest-accelerating-reliable-used-cars-from-the-1980s/) 14. Aviation Stack Exchange discussion about fastest accelerating aircraft on takeoff: - [https://aviation.stackexchange.com/questions/35482/what-is-the-fastest-accelerating-aircraft-on-takeoff](https://aviation.stackexchange.com/questions/35482/what-is-the-fastest-accelerating-aircraft-on-takeoff) 15. Executive Flyers article about how fast planes accelerate on the runway: - [https://executiveflyers.com/how-fast-do-planes-accelerate-on-the-runway/](https://executiveflyers.com/how-fast-do-planes-accelerate-on-the-runway/) 16. Aviation Stack Exchange discussion about aircraft carrier catapult acceleration: - [https://aviation.stackexchange.com/questions/45880/what-is-the-0-60mph-time-of-an-aircraft-carrier-catapult](https://aviation.stackexchange.com/questions/45880/what-is-the-0-60mph-time-of-an-aircraft-carrier-catapult) [#mcmurtry](/tag/mcmurtry)[#speirling](/tag/speirling)[#cars](/tag/cars)[#sports-cars](/tag/sports-cars)[#acceleration](/tag/acceleration) --- # What's new and what's next with AWS Docs Published: 2025-02-17 URL: https://gregwilson.tech/whats-new-aws-docs Tags: aws, amazon-web-services, documentation, generative-ai I have been at AWS for over 3 years now, leading the teams that build AWS Documentation and the AWS SDKs/CLI. It's been an incredibly fun ride, and I've learned a lot about the unique challenges of building and managing these types of systems at such... I have been at AWS for over 3 years now, leading the teams that build AWS Documentation and the AWS SDKs/CLI. It's been an incredibly fun ride, and I've learned a lot about the unique challenges of building and managing these types of systems at such an enormous scale. Since our documentation and SDKs are used by nearly every AWS customer, I believe our team needs to be more transparent about what we're working on and how we're prioritizing work to better incorporate your feedback. To that end, I’m going to start blogging about it. This first post provides an update on AWS Documentation, sharing details about recent content refactoring, website updates, new content types, and a sneak peek at how we're leveraging AI. I'll follow up soon with a similar update about the SDKs and CLI. The post is on the AWS Insights blog and can be found [here](https://aws.amazon.com/blogs/aws-insights/aws-documentation-update-progress-challenges-and-whats-next-for-2025/). I also shared it on: - [Reddit](https://www.reddit.com/r/aws/comments/1ipdyku/aws_documentation_update_refactored_content/) - [LinkedIn](https://www.linkedin.com/posts/gregorywilson_aws-documentation-update-progress-challenges-activity-7295866532503371776-pwO-/) - [BlueSky](https://bsky.app/profile/gregwilson.tech/post/3li3cf37mtc2w) [#aws](/tag/aws)[#amazon-web-services](/tag/amazon-web-services)[#documentation](/tag/documentation)[#generative-ai](/tag/generative-ai) --- # Stupid but useful AI tricks: Creating calendar entries for upcoming full moons using Claude 3.5 Sonnet Published: 2024-08-25 URL: https://gregwilson.tech/stupid-but-useful-ai-tricks-creating-calendar-entries-for-upcoming-full-moons-using-claude-35-sonnet Tags: claudeai, astronomy Create iCalendar entries for every full moon in the next 12 months using Claude 3.5 Sonnet As mentioned in [my previous post](https://gregsramblings.com/stupid-but-useful-ai-tricks-creating-calendar-entries-from-an-image-using-anthropic-claude-35), I recently discovered that Claude 3.5 Sonnet can create iCalendar (.ics) files, allowing me to generate calendar entries for almost anything. As a fun experiment, I tried the following prompt: > **My prompt: I live in San Francisco, CA. Create an icalendar file that creates a non-blocking all-day event for every full moon for the next 12 months. For each, indicate the type of full moon in the title of the event.** Claude responded: > Certainly! I'll create an iCalendar file for you with non-blocking all-day events for the full moons in the next 12 months, including the type of full moon in the title. This will be useful for tracking lunar cycles in San Francisco. ```plaintext BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Full Moon Calendar//EN CALSCALE:GREGORIAN BEGIN:VEVENT SUMMARY:Wolf Moon (Full Moon) DTSTART;VALUE=DATE:20240825 DTEND;VALUE=DATE:20240826 DTSTAMP:20240825T000000Z UID:20240825-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Snow Moon (Full Moon) DTSTART;VALUE=DATE:20240924 DTEND;VALUE=DATE:20240925 DTSTAMP:20240924T000000Z UID:20240924-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Hunter's Moon (Full Moon) DTSTART;VALUE=DATE:20241023 DTEND;VALUE=DATE:20241024 DTSTAMP:20241023T000000Z UID:20241023-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Beaver Moon (Full Moon) DTSTART;VALUE=DATE:20241122 DTEND;VALUE=DATE:20241123 DTSTAMP:20241122T000000Z UID:20241122-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Cold Moon (Full Moon) DTSTART;VALUE=DATE:20241221 DTEND;VALUE=DATE:20241222 DTSTAMP:20241221T000000Z UID:20241221-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Wolf Moon (Full Moon) DTSTART;VALUE=DATE:20250120 DTEND;VALUE=DATE:20250121 DTSTAMP:20250120T000000Z UID:20250120-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Snow Moon (Full Moon) DTSTART;VALUE=DATE:20250218 DTEND;VALUE=DATE:20250219 DTSTAMP:20250218T000000Z UID:20250218-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Worm Moon (Full Moon) DTSTART;VALUE=DATE:20250320 DTEND;VALUE=DATE:20250321 DTSTAMP:20250320T000000Z UID:20250320-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Pink Moon (Full Moon) DTSTART;VALUE=DATE:20250418 DTEND;VALUE=DATE:20250419 DTSTAMP:20250418T000000Z UID:20250418-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Flower Moon (Full Moon) DTSTART;VALUE=DATE:20250518 DTEND;VALUE=DATE:20250519 DTSTAMP:20250518T000000Z UID:20250518-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Strawberry Moon (Full Moon) DTSTART;VALUE=DATE:20250616 DTEND;VALUE=DATE:20250617 DTSTAMP:20250616T000000Z UID:20250616-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT SUMMARY:Buck Moon (Full Moon) DTSTART;VALUE=DATE:20250716 DTEND;VALUE=DATE:20250717 DTSTAMP:20250716T000000Z UID:20250716-fullmoon@fullmooncalendar.com TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ``` > I've created an iCalendar file with non-blocking all-day events for the full moons over the next 12 months, starting from today (August 25, 2024). Each event includes the type of full moon in its title. Here's a breakdown of the events: > > 1. August 25, 2024: Wolf Moon > > 2. September 24, 2024: Snow Moon > > 3. October 23, 2024: Hunter's Moon > > 4. November 22, 2024: Beaver Moon > > 5. December 21, 2024: Cold Moon > > 6. January 20, 2025: Wolf Moon > > 7. February 18, 2025: Snow Moon > > 8. March 20, 2025: Worm Moon > > 9. April 18, 2025: Pink Moon > > 10. May 18, 2025: Flower Moon > > 11. June 16, 2025: Strawberry Moon > > 12. July 16, 2025: Buck Moon > > > You can save this content as a .ics file and import it into your calendar application. The events are set as non-blocking (TRANSP:TRANSPARENT), so they won't interfere with your other scheduled events. I saved the file as fullmoon.ics and imported it to my Google calendar (go to settings, import/export). This will also work with ChatGPT, but you need to explicitly ask it to display the contents of the ics file. I just tried it with Google Gemini with no luck, but I'm sure it will in the future: ![Screenshot of Google Gemini responding to the prompt in this article. The response is "Sorry, I can't help with that"](/images/inline/stupid-but-useful-ai-tricks-creating-calendar-entries-for-upcoming-full-moons-using-claude-35-sonnet/bd9024e6-6c6c-404a-ba32-91b843858a3e.webp) [#claudeai](/tag/claudeai)[#astronomy](/tag/astronomy) --- # Stupid but useful AI tricks: Creating calendar entries from an image using Anthropic Claude 3.5 Published: 2024-08-25 URL: https://gregwilson.tech/stupid-but-useful-ai-tricks-creating-calendar-entries-from-an-image-using-anthropic-claude-35 Tags: icalendar, claudeai, calendar, image-processing Generate calendar entries from an image using Anthropic Claude 3.5. Convert lesson dates in an image to a calendar ICS file A few days ago, my jazz piano teacher sent me the new fall/winter schedule for my private jazz piano lessons -- 13 different dates -- as a JPG (mine are outlined in green marker): ![](/images/inline/stupid-but-useful-ai-tricks-creating-calendar-entries-from-an-image-using-anthropic-claude-35/b7807f68-6a22-45bc-85f0-78faadcaec63.webp) I was too lazy to go make 13 entries in Google Calendar, so I decided to see if Claude could help me out: I first uploaded the jpg to Claude 3.5 Sonnet)... > **My prompt:** List the dates that are outlined in green > > The dates outlined in green in the image are: > > Friday, 13-Sep-24 Friday, 27-Sep-24 Friday, 11-Oct-24 Friday, 8-Nov-24 Friday, 6-Dec-24 Friday, 20-Dec-24 Friday, 17-Jan-25 Friday, 31-Jan-25 Friday, 28-Feb-25 Friday, 14-Mar-25 Friday, 11-Apr-25 Friday, 25-Apr-25 Friday, 23-May-25 > > These dates correspond to the Friday column for students who have their first every-other-week lesson starting the week of Tuesday Sept 10, 2024. Cool - that was easy and accurate. Now I need to get it into my calendar, so I asked it to create an [ics](https://en.wikipedia.org/wiki/ICalendar) file... > **My prompt:** Create a calendar ics file that creates an appointment on each of those dates at 2pm pacific time. The appointment title will be “Jazz Piano Lesson” > > Certainly. I'll create an ICS (iCalendar) file with appointments for "Jazz Piano Lesson" on each of the dates you specified, set for 2:00 PM Pacific Time. ```plaintext BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Claude//Jazz Piano Lessons//EN CALSCALE:GREGORIAN BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20240913T210000Z DTEND:20240913T220000Z DTSTAMP:20240824T000000Z UID:20240913jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20240927T210000Z DTEND:20240927T220000Z DTSTAMP:20240824T000000Z UID:20240927jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20241011T210000Z DTEND:20241011T220000Z DTSTAMP:20240824T000000Z UID:20241011jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20241108T220000Z DTEND:20241108T230000Z DTSTAMP:20240824T000000Z UID:20241108jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20241206T220000Z DTEND:20241206T230000Z DTSTAMP:20240824T000000Z UID:20241206jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20241220T220000Z DTEND:20241220T230000Z DTSTAMP:20240824T000000Z UID:20241220jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20250117T220000Z DTEND:20250117T230000Z DTSTAMP:20240824T000000Z UID:20250117jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20250131T220000Z DTEND:20250131T230000Z DTSTAMP:20240824T000000Z UID:20250131jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20250228T220000Z DTEND:20250228T230000Z DTSTAMP:20240824T000000Z UID:20250228jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20250314T210000Z DTEND:20250314T220000Z DTSTAMP:20240824T000000Z UID:20250314jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20250411T210000Z DTEND:20250411T220000Z DTSTAMP:20240824T000000Z UID:20250411jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20250425T210000Z DTEND:20250425T220000Z DTSTAMP:20240824T000000Z UID:20250425jazzpiano@claude.ai END:VEVENT BEGIN:VEVENT SUMMARY:Jazz Piano Lesson DTSTART:20250523T210000Z DTEND:20250523T220000Z DTSTAMP:20240824T000000Z UID:20250523jazzpiano@claude.ai END:VEVENT END:VCALENDAR ``` > I've created an ICS file with the following details: > > 1. Each event is titled "Jazz Piano Lesson" > > 2. The events are scheduled for 2:00 PM Pacific Time on each of the specified dates > > 3. Each event is set to last for one hour > > 4. The time zone is properly adjusted to UTC (Z) in the file > > 5. Unique identifiers (UIDs) are provided for each event > > > You can save this content as a .ics file and import it into most calendar applications. Here are a few notes about the file: > > 1. The times are in UTC (Coordinated Universal Time). 2:00 PM Pacific Time translates to 9:00 PM UTC (21:00) for dates not in Daylight Saving Time, and 10:00 PM UTC (22:00) for dates within Daylight Saving Time. The file accounts for these changes. > > 2. The DTSTAMP for all events is set to the date of this conversation (August 24, 2024) as a placeholder. Your calendar application will likely update this when you import the events. > > 3. The UID for each event is unique, based on the date of the lesson and a simple string. > > > To use this file: > > 1. Copy the content of the artifact > > 2. Paste it into a new text file > > 3. Save the file with a .ics extension (e.g., "jazz\_piano\_lessons.ics") > > 4. Import this file into your preferred calendar application > > > Most calendar applications should be able to read this file and create the appropriate recurring events. Let me know if you need any modifications or have any questions! I went to calendar.google.com, imported the ics, and my appointments were imported perfectly! (To import ics files into Google calendar, go to the calendar settings, then to import/export) I'm pleasantly surprised at how easy this was! I tried the exact same thing in ChatGPT -- it identified the dates in the images, but it said it was was unable to create an .ics file. However, it did give me some Python code to create the ics file for me. I came back to that conversation later and said, "Just show me the content of the .ics file" and it did! [#icalendar](/tag/icalendar)[#claudeai](/tag/claudeai)[#calendar](/tag/calendar)[#image-processing](/tag/image-processing) --- # Bricking a Mars probe Published: 2024-08-22 URL: https://gregwilson.tech/bricking-a-mars-probe Tags: space-exploration Discover how NASA's Viking 1 and the Soviet Union's Phobos 1 missions encountered software mishaps and what engineers did to overcome these challenges [Soviet oops – Phobos 1](#heading-soviet-oops-phobos-1) · [Software Heroes – Galileo](#heading-software-heroes-galileo) Like many of you, I love following space exploration and have always been a space program fanatic. When I was a kid, I knew more details about the Mercury, Gemini and Apollo programs than any other kid or teacher in school. The recent Curiosity program got me thinking back about the old Mars Viking program. In 1976, NASA landed two probes on Mars that sent back the first pictures from the surface of the planet in history. This was the first time a human-created device had landed on the planet, so there were a ton of unknowns. I was very young, but I remember waiting for those first pictures from Mars, anxious to find out if the little green men would be friendly. Here’s the first panorama ever sent from Mars (shot by the Viking 1 lander – click it to go to the source and high-res versions): [![](/images/inline/bricking-a-mars-probe/Mars_Viking_12a002.webp)](https://upload.wikimedia.org/wikipedia/commons/1/10/Mars_Viking_12a002.png) I was reading about the Viking missions and stumbled on a little-known, yet fascinating fact about the Viking 1 lander. On November 11, 1982, an over-the-air software update was broadcast to the Viking 1 lander to correct a battery charging issue. The update had an error in it and accidentally overwrote the wrong bits, resulting in a bricked lander. Basically, they accidentally overwrote the memory reserved for the antenna-pointing code, which immediately terminated all communications. The mission had already been a huge success, so it wasn’t a huge deal in the grand scheme of things, but I bet those responsible for the error had a big “oh crap! We just killed the probe!” moment. I’d love to meet these folks. I bet it’s a great story. I imagine a few NASA engineers in a conference room going through a list of memory addresses that were part of the update, trying to figure out why they didn’t get any acknowledgement from the probe, and then finding the mistake and looking at each other in shocked disbelief and fear. I can remember working from home on a production system and accidentally typing in the wrong iptables command, resulting in the instant inability to connect to the server. At least I was able to drive 30 minutes to the data center and connect a console to the server and undo my mistake! ## Soviet oops – Phobos 1 [![](/images/inline/bricking-a-mars-probe/Phobos_Marte.webp)](https://upload.wikimedia.org/wikipedia/commons/0/01/Phobos_Marte.jpg) I also learned that a similar mistake happened with the Soviet Phobos 1 orbital Mars mission. From [Wikipedia’s Phobos program article](http://en.wikipedia.org/wiki/Phobos_program) — > “Phobos 1 operated nominally until an expected communications session on September 2, 1988 failed to occur. The failure of controllers to regain contact with the spacecraft was traced to an error in the software uploaded on August 29/August 30, which had deactivated the attitude thrusters. By losing its lock on the Sun, the spacecraft could no longer properly orient its solar arrays, thus depleting its batteries. > > Software instructions to turn off the probe’s attitude control, normally a fatal operation, were part of a routine used when testing the spacecraft on the ground. Normally this routine would be removed before launch. However, the software was coded in PROMs, and so removing the test code would have required removing and replacing the entire computer. Because of time pressure from the impending launch, engineers decided to leave the command sequence in, though it should never be used. However, a single-character error in constructing an upload sequence resulted in the command executing, with subsequent loss of the spacecraft.” Oops! ## Software Heroes – Galileo [![](/images/inline/bricking-a-mars-probe/1920px-Artwork_Galileo-Io-Jupiter.webp)](https://en.wikipedia.org/wiki/Galileo_\(spacecraft\)) I also stumbled on a fascinating story about the NASA Galileo Jupiter probe launched in 1989. During its long trip to Jupiter, the high-gain antenna failed to open, even after many ingenious attempts at spinning the probe to free it (among other wild tactics). This meant that NASA was stuck using the low-gain antenna. The difference in bandwidth was dramatic — 10bps vs 134kbps (over 13,000 times slower)! While the crippled probe was hurtling on the way to Jupiter, software engineers had to do some serious software rewriting to try and salvage as much of the mission as possible. They rewrote the encoding/decoding software to get better compression and optimized the image compression algorithms among many other improvements. Then they had to push these updates to the probe. Can you imagine the stress? Amazing stuff. More details in NASA’s [“Galileo’s Rocky Road to Jupiter” (PDF)](https://www.nasa.gov/pdf/546504main_42s_galilieo_rocky_road_jupiter.pdf) and the LPI’s [Galileo mission article](http://www.lpi.usra.edu/publications/newsletters/lpib/lpib76/gal76.html). Software developers who write code for spacecraft are my heroes. They work under incredible stress and have to cope with extremely low bandwidth, very limited resources, and a non-touchable, non-upgradable hardware platform — all while typically writing very low-level code. There are engineers still occasionally sending updates to the Voyager probes launched in the ’70s! Voyager 1 downlinks data at 160bps (uplinked commands are even slower, at 16bps) and is now so far away that one-way signals take roughly 23 hours — it’s expected to cross the full one-light-day mark in November 2026. I get impatient when a git pull takes more than 5 seconds! Lastly, I was reminded by a friend of yet another mission where engineers saved the day — the Huygens Titan probe, which separated from Cassini in December 2004 and landed on Titan in January 2005. Read all about it at [The Space Review](http://www.thespacereview.com/article/306/1). [#space-exploration](/tag/space-exploration) --- # How to Use Amazon Bedrock APIs for Anthropic Claude 3.5 Sonnet in Python Published: 2024-08-20 URL: https://gregwilson.tech/how-to-use-aws-bedrock-apis-for-anthropic-claude-35-sonnet-in-python Tags: claudeai, amazon-bedrock, aws-bedrock, python, claude-35 A guide on using Amazon Bedrock APIs for Anthropic Claude 3.5 Sonnet in Python, including setup, differences, and error handling The Anthropic APIs and Python SDK are solid, but they do have some limits that might make certain tasks challenging, especially as of this writing. There is an alternative path to using Anthropic's models - [Amazon Bedrock](https://aws.amazon.com/bedrock). Amazon Bedrock now offers access to Anthropic's Claude models, [including Claude 3.5 Sonnet](https://aws.amazon.com/blogs/aws/anthropics-claude-3-5-sonnet-model-now-available-in-amazon-bedrock-the-most-intelligent-claude-model-yet/). If you have the need for different limits or if you are integrating with other AWS services, it might make sense to migrate your existing Python code from using the Anthropic API to the Amazon Bedrock APIs. This guide will step you through migrating your code. It assumes you're already familiar with Anthropic and AWS authentication. I decided not to delve into the differences in requests-per-minute, tokens-per-minute, tokens-per-day, API cost, etc., as these change frequently and explaining all of this would require significantly more text. Do your research to ensure this is the right path for you. Personally, I encountered the tokens-per-day limit with Anthropic APIs when asking Claude to provide descriptions for thousands of images. Switching to Bedrock allowed me to complete this task in a single run. ## 1\. Installing Required Libraries First, ensure you have the necessary libraries installed: ```bash pip install anthropic boto3 ``` ## 2\. Importing Libraries and Setting Up Clients Replace your Anthropic imports with AWS SDK for Python (Boto3): ```python # Old Anthropic import # from anthropic import Anthropic # New Amazon Bedrock import import boto3 import json ``` Then, set up the Amazon Bedrock client: ```python # Old Anthropic client setup # anthropic = Anthropic(api_key="your-api-key") # New Amazon Bedrock client setup bedrock_runtime = boto3.client( service_name='bedrock-runtime', # see sample code below for how I used Claude 3.5 Sonnet model region_name='your-aws-region' # check docs - not all models are available in all regions ) ``` ## 3\. Constructing the Request The request structure for Amazon Bedrock differs from Anthropic's API. Here's how to adapt your code: ```python # Old Anthropic request # response = anthropic.messages.create( # model="claude-3-sonnet-20240229", # max_tokens=300, # temperature=0.7, # messages=[ # {"role": "user", "content": "Your message here"} # ] # ) # New Amazon Bedrock request request_body = json.dumps({ "anthropic_version": "bedrock-2023-05-31", "max_tokens": 300, "temperature": 0.7, "messages": [ {"role": "user", "content": "Your message here"} ] }) ``` ## 4\. Sending the Request and Handling the Response Now, let's send the request and process the response: ```python # Send request to Amazon Bedrock response = bedrock_runtime.invoke_model( modelId='anthropic.claude-3-5-sonnet-20240620-v1:0', body=request_body ) # Parse the response response_body = json.loads(response['body'].read()) generated_text = response_body['content'][0]['text'] print(generated_text) ``` ## 5\. Error Handling Adapt your error handling to work with Amazon Bedrock: ```python from botocore.exceptions import ClientError try: response = bedrock_runtime.invoke_model( modelId='anthropic.claude-3-5-sonnet-20240620-v1:0', body=request_body ) # Process response as shown above except ClientError as e: print(f"An error occurred: {e.response['Error']['Message']}") ``` ## 6\. Key Differences to Note 1. **Authentication**: Amazon Bedrock uses AWS credentials instead of an API key. 2. **Request Structure**: Amazon Bedrock requires a JSON string for the request body. 3. **Model Specification**: The model is specified as a `modelId` parameter in the `invoke_model()` call. 4. **Response Parsing**: The response from Amazon Bedrock needs to be parsed from a JSON structure. 5. **Error Handling**: Use boto3's `ClientError` for catching AWS-specific exceptions. ## 7\. Important Considerations for Amazon Bedrock Setup - Ensure your AWS credentials have the necessary permissions to access Bedrock services. - Check that the Claude 3.5 Sonnet model (anthropic.claude-3-5-sonnet-20240620-v1:0) is available in your chosen AWS region (it's only in a few as of this writing). - Verify that your AWS account has been granted access to the Claude 3.5 Sonnet model. If not, you can simply [request access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) through the console and have access in a few minutes. - The `anthropic_version` parameter in the request body may need updating based on the latest Bedrock API version. ## 8\. Making it all work ### A simple Anthropic API example: ```python from anthropic import Anthropic import os # Set up the Anthropic client anthropic = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) # Define the message message = "What's the difference between Amazon Aurora and RDS?" # Send the request to Claude 3.5 Sonnet using the Messages API response = anthropic.messages.create( model="claude-3-sonnet-20240229", max_tokens=300, temperature=0.7, messages=[ {"role": "user", "content": message} ] ) # Print the response print(response.content[0].text) ``` ### Converted to run using Amazon Bedrock ```python import boto3 import json import os from botocore.exceptions import ClientError # Set up the Amazon Bedrock client bedrock_runtime = boto3.client( service_name='bedrock-runtime', region_name='us-east-1', # Replace with your AWS region aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY") ) # Define the message message = "What's the difference between Amazon Aurora and RDS?" # Prepare the request body request_body = json.dumps({ "anthropic_version": "bedrock-2023-05-31", "max_tokens": 300, "temperature": 0.7, "messages": [ {"role": "user", "content": message} ] }) # Specify the Claude 3.5 Sonnet model ID model_id = 'anthropic.claude-3-5-sonnet-20240620-v1:0' try: print(f"Invoking model: {model_id}") response = bedrock_runtime.invoke_model( modelId=model_id, body=request_body ) # Parse and print the response response_body = json.loads(response['body'].read()) print("Response:", response_body['content'][0]['text']) except ClientError as e: error_code = e.response['Error']['Code'] error_message = e.response['Error']['Message'] print(f"Error: {error_code} - {error_message}") except Exception as e: print(f"Unexpected error: {str(e)}") # Print debugging information print("\nDebugging Information:") print(f"AWS Region: {bedrock_runtime.meta.region_name}") print(f"Model used: {model_id}") print("Please ensure you have the correct permissions and that this model is available in your region.") ``` ## Alternative: Leveraging Claude for Code Conversion While manually converting your code from the Anthropic API to Amazon Bedrock is a valuable exercise in understanding the differences between the two services, there's an alternative approach that can save time and potentially catch nuances you might miss: using Claude 3.5 Sonnet itself to assist with the conversion process. I was able to make this work, but I did have to do a bit of 'back and forth' with Claude 3.5 Sonnet to get working code. Even with the minor challenges, it still saved me time! Here's how you can leverage Claude's capabilities: 1. **Prepare Your Existing Code**: Gather the Anthropic API code you want to convert. Ensure it's well-commented and includes any relevant context. 2. **Craft a Clear Request**: Ask Claude to convert your code, providing specific instructions. For example: "Please convert the following Python code that uses the Anthropic API with Claude 3.5 Sonnet to use Amazon Bedrock instead. Maintain the same functionality and include error handling. Here's the code: \[paste your code here\]" 3. **Review and Refine**: Examine the converted code provided by Claude. It may catch subtleties in the API differences that aren't immediately obvious. If needed, ask for clarifications or modifications. 4. **Iterate if Necessary**: If the converted code isn't quite right, provide feedback to Claude and ask for adjustments. For instance: "The conversion looks good, but could you add more robust error handling for AWS-specific exceptions?". 5. **Test Thoroughly**: Once satisfied with the conversion, test the new Amazon Bedrock code thoroughly to ensure it maintains the functionality of your original implementation. This approach can be particularly beneficial when: - You're converting a large amount of code - You're unfamiliar with the intricacies of the Amazon Bedrock API - You want to ensure you're following best practices for Amazon Bedrock integration If there is one thing I've learned from using Claude 3.5 and other LLMs to write code -- **Always** review and test the converted code in your own environment. **UPDATE on August 19th, 2024** - A reader pointed out to me that the Anthropic API offers a path to Bedrock as well! This might be a simpler path depending on your needs. Check it out - [https://docs.anthropic.com/en/api/claude-on-amazon-bedrock](https://docs.anthropic.com/en/api/claude-on-amazon-bedrock) [#claudeai](/tag/claudeai)[#amazon-bedrock](/tag/amazon-bedrock)[#aws-bedrock](/tag/aws-bedrock)[#python](/tag/python)[#claude-35](/tag/claude-35) --- # Fun with Anthropic Claude and image file geo-location Published: 2024-08-19 URL: https://gregwilson.tech/fun-with-anthropic-claude-and-image-file-locations Tags: claudeai, claude-35, photography, geolocation, exiftool Learn how to use Anthropic Claude AI to extract and utilize image file geo-locations in this detailed, real-world coding experiment I've been a photographer for decades, and as a result, I have over 100,000 image files in various formats, many of which include location information. I thought it would be fun to experiment with this location data. The task turned out to be a bit tougher than I expected, but it was a great chance to see how well [Anthropic Claude](https://claude.ai/) could help debug. I ended up with 11 versions of the code as I fixed errors and expanded the scope. To keep this post brief, I'll only share the final code and skip the interim versions. However, I will also include all of my exchanges with Claude so you can see how this process works in a real-world scenario. I never touched the code that Claude generated, otherwise, I would have run this in an IDE with the appropriate integrations with Claude (see [this article](https://claude3.us/claude-3-for-coding/) for some examples). I started simple to make sure I could even extract location data from my test images and asked Claude (3.5 Sonnet) to write some code for me: My Prompt: > Write a Python script that takes a list of image files as parameters. For each image file, list the filename, date created, and location information. Image formats will include jpg, nef, and heic (Apple iphone images). As requested, Claude created some Python code for me and asked me to install the following Pillow and piexif libraries: `pip install Pillow piexif` I tried the generated code, got an **error**, and told Claude about it: > I get the following error when testing with a jpg: > Traceback (most recent call last): File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 87, in main(sys.argv\[1:\]) File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 74, in main info = process\_image(image\_file) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 63, in process\_image location = get\_gps\_info(exif\_data) ^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 44, in get\_gps\_info return f"{lat:.6f}, {lon:.6f}" ^^^^^^^^^^^^^^^^^^^^^^^ TypeError: unsupported format string passed to Fraction.format **Claude responded:** `I apologize for the oversight. The error you're encountering is due to the GPS coordinates being stored as Fraction objects in some EXIF data, which can't be formatted directly with the f-string. Let's modify the get_gps_info function to handle this case.` `These changes should resolve the error you were seeing. The script now handles GPS coordinates stored as tuples (degrees, minutes, seconds) or as Fraction objects, which are common formats for GPS data in EXIF.` `This updated version should work with JPG files that store GPS coordinates in various formats. It still maintains compatibility with NEF a`nd HEIC files as well. Everything worked with jpg files, but my heic files didn't, so I told Claude about it: > Date created and location are unknown with all heic files **Claude responded:** `I apologize for the oversight with HEIC files. You're right that the current implementation isn't correctly extracting the date and location information from HEIC files. Let's modify the script to properly handle HEIC metadata. We'll use the pyheif library in addition to piexif to better process HEIC files.` Claude asked me to install pyheif - **pip install pyheif** - but I got errors trying to install it, so I told Claude about it: > pyheif won't install **Claude responded:** `I apologize for the difficulty you're experiencing with installing pyheif. You're right that it can sometimes be challenging to install due to its dependencies. Let's try an alternative approach using pillow-heif, which is generally easier to install and use. Here's an updated version of the script that uses pillow-heif instead of pyheif:` I installed pillow-heif as instructed and tried the newly generated code, but got another **error**, so I told Claude about it: > I'm getting the following error: > Traceback (most recent call last): File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 82, in main(sys.argv\[1:\]) File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 69, in main info = process\_image(image\_file) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 58, in process\_image location = get\_gps\_info(exif\_data) ^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/gregwilson/Downloads/imageplay/[image-metadata-script.py](http://image-metadata-script.py)", line 38, in get\_gps\_info lat = gps\_info.get(2) ^^^^^^^^^^^^ AttributeError: 'int' object has no attribute 'get' **Claude responded:** `I apologize for the oversight. The error you're encountering suggests that the GPS information is not being stored as expected in the EXIF data. Let's modify the get_gps_info function to handle different possible structures of GPS data.` This worked for jpg files, so now we're getting somewhere! However, for heic files, it didn't work, so I told Claude about it: > Location is unknown in all heic files **Claude responded:** `I apologize for the continued difficulty with HEIC files. It seems that the GPS data in HEIC files might be stored differently or not easily accessible through the current method. Let's try a different approach using the exif library, which is specifically designed to handle EXIF data and should work better with HEIC files.` I tried the new code, got a new error, and told Claude about it: > Getting following error on heic files: > Error reading EXIF data for IMG\_1837.heic: > > +--------+------------+-------+-------+------------------------+ | Offset | Access | Value | Bytes | Format | +--------+------------+-------+-------+------------------------+ | | | | | TiffHeader (Structure) | | 0 | byte\_order | 11565 | 2d 2d | tiff\_byte\_order | +--------+------------+-------+-------+------------------------+ **Claude responded:** `I apologize for the continued issues. It seems that the exif library is having trouble reading the HEIC files directly. Let's try a different approach using pillow_heif to open the HEIC files and then extract the EXIF data.` I downloaded the new code, and **it worked**! Below is the output: ```plaintext python3 image-metadata-script.py *heic *jpg Filename: IMG_1837.heic Date Created: 2023-12-26 21:27:59 Location: 37.659542, -122.294072 ---------------------------------------- Filename: IMG_3724.heic Date Created: 2023-08-08 19:59:54 Location: 37.659483, -122.294006 ---------------------------------------- Filename: IMG_3725.heic Date Created: 2023-08-08 20:01:46 Location: 37.659525, -122.293936 ---------------------------------------- Filename: DSC_7148.jpg Date Created: 2015-08-27 09:36:46 Location: 48.862175, 2.323205 ---------------------------------------- Filename: PANO0001 Panorama-106.jpg Date Created: 2023-09-07 18:17:21 Location: 37.659570, -122.293875 ---------------------------------------- Filename: PANO0001 Panorama.jpg Date Created: 2023-09-07 18:15:53 Location: 37.6759570, -122.293872 ---------------------------------------- ``` I obviously had to wrestle Claude a bit to get this working, but it's impressive nonetheless. I decided to take it to the next level and have Claude add some code to convert the lat/long to city, state, country. I asked Claude: > That works. Now add the city, state, country of each image location **Claude responded:** `Great to hear that the script is now working for your HEIC files! Let's enhance the script to add city, state, and country information for each image location. We'll use the geopy library to perform reverse geocoding based on the GPS coordinates.` I installed the require libraries, downloaded the new code, tried it, got an **error**, and told Claude about it: > Getting error: > Error getting location details: HTTPSConnectionPool(host='[nominatim.openstreetmap.org](http://nominatim.openstreetmap.org)', port=443): Max retries exceeded with url: /reverse?lat=37.759541666666664&lon=-122.39407222222223&format=json&addressdetails=1 (Caused by SSLError(SSLCertVerificationError(1, '\[SSL: CERTIFICATE\_VERIFY\_FAILED\] certificate verify failed: unable to get local issuer certificate (\_ssl.c:1006)'))) Claude responded: `I apologize for the SSL certificate verification error you're encountering. This issue is likely related to your Python installation not having the proper SSL certificates. Let's modify our script to work around this issue while still maintaining security. We'll add an option to disable SSL verification, but we'll make sure to warn the user about the potential security implications.` Claude also provided instructions for how to get my SSL cert installed and working, but I didn't want to do that for this little exercise, so I downloaded the new code, tried it, and it worked! Below is the new output: ```plaintext ---------------------------------------- Filename: DSC_7148.jpg Date Created: 2015-08-27 09:36:46 Location: 48.862175, 2.323205 Location Details: Paris, Île-de-France, France ---------------------------------------- ``` I decided to try one more thing... I asked Claude: > Works perfectly. Now determine the distance between all images and determine the two images with the most distance between them. **Claude responded:** `Great to hear it's working perfectly! Let's enhance the script to calculate the distances between all images and find the two images that are farthest apart. We'll use the geopy.distance module to calculate the distances between coordinates.` I downloaded the new code and it worked perfectly. I gave it a handful of images and it output: ```plaintext The two images farthest apart are: 1. DSC_7148.jpg (Paris, Île-de-France, France) 2. IMG_3724.heic (San Francisco, California, United States) Distance between them: 8974.51 km ``` ## The final code: ```python import os import sys from datetime import datetime from PIL import Image from pillow_heif import register_heif_opener, HeifImagePlugin from exif import Image as ExifImage import io from geopy.geocoders import Nominatim from geopy.distance import geodesic from geopy.exc import GeocoderTimedOut, GeocoderUnavailable import time import ssl import certifi import argparse from itertools import combinations # Register HEIF opener with Pillow register_heif_opener() def create_ssl_context(verify_ssl): if verify_ssl: return ssl.create_default_context(cafile=certifi.where()) else: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx def initialize_geolocator(verify_ssl): ssl_context = create_ssl_context(verify_ssl) return Nominatim(user_agent="image_metadata_script", scheme='https', ssl_context=ssl_context) def get_exif_data(image_path): try: if image_path.lower().endswith('.heic'): with Image.open(image_path) as img: exif_data = img.getexif() exif_bytes = io.BytesIO() img.save(exif_bytes, format='JPEG', exif=exif_data) exif_bytes.seek(0) return ExifImage(exif_bytes) else: with open(image_path, 'rb') as image_file: return ExifImage(image_file) except Exception as e: print(f"Error reading EXIF data for {image_path}: {str(e)}") return None def get_creation_date(exif_data): try: if hasattr(exif_data, 'datetime_original'): return datetime.strptime(str(exif_data.datetime_original), '%Y:%m:%d %H:%M:%S') elif hasattr(exif_data, 'datetime'): return datetime.strptime(str(exif_data.datetime), '%Y:%m:%d %H:%M:%S') except Exception as e: print(f"Error parsing creation date: {str(e)}") return None def convert_to_degrees(dms, ref): degrees = dms[0] minutes = dms[1] / 60.0 seconds = dms[2] / 3600.0 if ref in ['S', 'W']: return -(degrees + minutes + seconds) else: return degrees + minutes + seconds def get_gps_info(exif_data): try: if hasattr(exif_data, 'gps_latitude') and hasattr(exif_data, 'gps_longitude'): lat = convert_to_degrees(exif_data.gps_latitude, exif_data.gps_latitude_ref) lon = convert_to_degrees(exif_data.gps_longitude, exif_data.gps_longitude_ref) return lat, lon except Exception as e: print(f"Error processing GPS info: {str(e)}") return None def get_location_details(geolocator, lat, lon): max_retries = 3 for attempt in range(max_retries): try: location = geolocator.reverse(f"{lat}, {lon}") if location: address = location.raw['address'] city = address.get('city') or address.get('town') or address.get('village') or 'Unknown' state = address.get('state') or 'Unknown' country = address.get('country') or 'Unknown' return f"{city}, {state}, {country}" return "Unknown location" except (GeocoderTimedOut, GeocoderUnavailable) as e: if attempt < max_retries - 1: time.sleep(1) else: print(f"Error getting location details: {str(e)}") return "Unknown location" def process_image(image_path, geolocator): filename = os.path.basename(image_path) exif_data = get_exif_data(image_path) creation_date = get_creation_date(exif_data) if exif_data else None gps_info = get_gps_info(exif_data) if exif_data else None location = "Unknown" location_details = "Unknown location" if gps_info: lat, lon = gps_info location = f"{lat:.6f}, {lon:.6f}" location_details = get_location_details(geolocator, lat, lon) return { "filename": filename, "creation_date": creation_date.strftime('%Y-%m-%d %H:%M:%S') if creation_date else "Unknown", "location": location, "location_details": location_details, "gps_coords": gps_info } def calculate_distances(image_infos): distances = [] for (img1, info1), (img2, info2) in combinations(image_infos.items(), 2): if info1['gps_coords'] and info2['gps_coords']: distance = geodesic(info1['gps_coords'], info2['gps_coords']).miles distances.append((img1, img2, distance)) return distances def find_farthest_images(distances): if not distances: return None, None, 0 return max(distances, key=lambda x: x[2]) def main(image_files, verify_ssl): geolocator = initialize_geolocator(verify_ssl) image_infos = {} for image_file in image_files: if os.path.exists(image_file): info = process_image(image_file, geolocator) image_infos[image_file] = info print(f"Filename: {info['filename']}") print(f"Date Created: {info['creation_date']}") print(f"Location: {info['location']}") print(f"Location Details: {info['location_details']}") print("-" * 40) else: print(f"File not found: {image_file}") print("-" * 40) distances = calculate_distances(image_infos) farthest_img1, farthest_img2, max_distance = find_farthest_images(distances) if farthest_img1 and farthest_img2: print(f"\nThe two images farthest apart are:") print(f"1. {image_infos[farthest_img1]['filename']} ({image_infos[farthest_img1]['location_details']})") print(f"2. {image_infos[farthest_img2]['filename']} ({image_infos[farthest_img2]['location_details']})") print(f"Distance between them: {max_distance:.2f} miles") else: print("\nUnable to calculate distances. Ensure at least two images have valid GPS coordinates.") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Process image metadata, location information, and calculate distances in miles.") parser.add_argument("image_files", nargs="+", help="Image files to process") parser.add_argument("--no-verify-ssl", action="store_true", help="Disable SSL certificate verification (use with caution)") args = parser.parse_args() if args.no_verify_ssl: print("WARNING: SSL certificate verification is disabled. This is not recommended for production use.") print("To fix SSL issues properly, try updating your SSL certificates:") print("pip install --upgrade certifi") print("If issues persist, consult your system administrator or Python installation documentation.") print() main(args.image_files, not args.no_verify_ssl) ``` Going through this exercise gave me a much better sense of the kind of coding the Claude and other LLMs is capable of and how debugging can work. For my next project, I might take all of the images from a specific year, extract the location, and create a KML file so I can [visualize all of the images in Google Earth](https://support.google.com/earth/answer/7365595?hl=en&co=GENIE.Platform%3DDesktop). I have no doubt that I could get this done quickly. [#claudeai](/tag/claudeai)[#claude-35](/tag/claude-35)[#photography](/tag/photography)[#geolocation](/tag/geolocation)[#exiftool](/tag/exiftool) --- # How I Supercharged My Synology DS723+ for Adobe Lightroom Published: 2024-08-15 URL: https://gregwilson.tech/how-i-supercharged-my-synology-ds723-for-adobe-lightroom Tags: synology, synology-nas, adobe-lightroom Supercharge your Synology DS723+ for professional photo editing with NVMe SSDs and a 10GbE network card. As a frequent photographer, my Synology DS723+ NAS is the heart of my digital workflow. Recently, I decided to upgrade it with NVMe SSDs and a 10GbE network card. These two modifications have dramatically improved my workflow, especially when working with Adobe Lightroom. Here's my experience with these upgrades and how they've transformed my photo editing process. ## My Photography Workflow Before diving into the upgrades, let me explain my setup. I use my DS723+ primarily as a centralized storage solution for my extensive photo library. Adobe Lightroom is my go-to software for accessing and editing these photos. Working with high-resolution RAW files demands maximum performance from both my storage and network. Two upgrades I installed: - [Synology M.2 2280 NVMe SSD SNV3410 400GB (SNV3410-400G)](https://amzn.to/3SLy9oz) - [Synology Network Upgrade Module adds 1x 10GbE RJ-45 (E10G22-T1-Mini)](https://amzn.to/3yEwdY5) (Both of these were available on Amazon) ## Upgrade 1: Adding NVMe SSDs ### The Installation Process Installing the NVMe SSDs was straightforward: 1. I powered down the DS723+ and unplugged it. 2. Removed the cover (following the user manual). 3. Located the two M.2 NVMe slots on the bottom of the motherboard. 4. Inserted the SSDs and secured them. 5. Replaced the cover and powered on the NAS. 6. Used DSM (DiskStation Manager) to initialize and configure the new SSDs. ## Upgrade 2: Adding a 10GbE Network Card ### The Installation Process Adding the 10GbE network card was also straightforward: 1. Powered down and unplugged the NAS. 2. Removed the cover. 3. Inserted the 10GbE card into the PCIe slot and secured it. 4. Replaced the cover and powered on the NAS. 5. Configured the new network interface in DSM and connect it to my 10Gb port on my router ([Ubiquity Dream Machine SE](https://store.ui.com/us/en/collections/unifi-dream-machine/products/udm-se)) ## The Overall Impact on My Workflow These upgrades have truly transformed my Lightroom workflow. Tasks that used to take minutes now complete in seconds. Importing photos, generating previews, scrolling through my library, and exporting edited images - all these processes are now significantly faster. More importantly, these performance improvements have made my editing process more enjoyable and creative. I'm no longer waiting for everything to catch up with my workflow. Instead, I can focus on my images. The immediate caching of new uploads is particularly game-changing. After a long shoot, I can start working on my photos almost instantly after uploading them. Other Synology NAS devices support these upgrades - see their website for details. [#synology](/tag/synology)[#synology-nas](/tag/synology-nas)[#adobe-lightroom](/tag/adobe-lightroom) --- # Playing with Claude 3.5 Sonnet for code generation and image alt-text descriptions Published: 2024-08-15 URL: https://gregwilson.tech/playing-with-claude-35-sonnet-for-code-generation-and-image-alt-text-descriptions Tags: claudeai, ai-code-generator I had a little time to relax over the weekend, so I decided to try a small coding project with Anthropic Claude 3.5 Sonnet. I wanted to test the new 3.5 Sonnet model and also explore the artifacts feature. Claude 3.5 Sonnet is very good at describing... I had a little time to relax over the weekend, so I decided to try a small coding project with Anthropic Claude 3.5 Sonnet. I wanted to test the new 3.5 Sonnet model and also explore the [artifacts feature](https://support.anthropic.com/en/articles/9487310-what-are-artifacts-and-how-do-i-use-them). Claude 3.5 Sonnet is very good at describing images that contain technical diagrams and screenshots, so I decided to try writting some code to auto-generate alt-text for images in any HTML file. Here's the thing -- I've never written Python code. I'm more of a Java and Node kind of guy, so by choosing Python, I was really testing my ability to generate code from scratch using some simple prompting. Below is my chat with Claude. Although I'm not an expert at this type of prompting yet, I was able to hack my way through it. After most prompts, Claude created new code for me that was replaced after the subsequent prompts, so I omitted the early Claude responses to make this easier to read. I started by testing if I could get it to describe local images of varying formats (JPG, HEIC, PNG). Once this was successful, I expanded the scope to include parsing the images from HTML, injecting new ALT attributes, and more. This entire project took me only 20 minutes which I found super exciting. Even if I wrote in a programming language I'm intimately familiar with, I could have never completed this as fast because I'd have to figure out to handle PNGs, JPGs, HEIC, the Anthropic APIs, and more. * * * **Greg**: Are you familiar with the Claude API? **Claude 3.5 Sonnet**: Yes, I am familiar with the Claude API. It allows developers to integrate Anthropic's language models, including versions of me, into their applications and services. The API provides access to capabilities like natural language understanding, text generation, and task completion. However, I don't have detailed knowledge about its exact specifications, pricing, or implementation details. For the most up-to-date and accurate information about the Claude API, I'd recommend checking Anthropic's official documentation and resources. **Greg**: I have a list of image URLs. I need a 125 char description of each. Write python code to do this with Claude 3.5 Sonnet. (Claude output omitted for brevity) **Greg**: I will provide the list of image URLs in a file named images.csv (Claude output omitted for brevity) **Greg**: print each url as you read the file (Claude output omitted for brevity) **Greg**: Getting an error - 'url' found using 'type' does not match any of the expected tags: 'base64'" **Claude 3.5 Sonnet**: I apologize for the confusion. It seems the error is related to how we're passing the image URL to the Claude API. The API is expecting a base64-encoded image rather than a URL. Let's modify the script to handle this correctly. We'll need to download the image and convert it to base64 before sending it to the API. Here's an updated version of the script that should resolve this issue: **Greg**: This only handles jpg. I need it to also handle png (Claude output omitted for brevity) **Greg**: Output the results to a new csv file. The columns of the csv file should be (1) image url and (2) image description. (Claude output omitted for brevity) **Greg**: add gif support including animated gif (Claude output omitted for brevity) (I had to install PIL) **Greg**: Can you add webp support? (Claude output omitted for brevity) **Greg**: I want the option to pass a parameter to the program. The parameter will be a URL of a webpage. Read the web page and use all of the image URLs in the page instead of the input file currently used (Claude output omitted for brevity) (I had to install BeautifulSoup) **Greg**: Modify the program to output the same HTML that it reads but insert each of the image descriptions as the "alt" of the image tag. (Claude output omitted for brevity) **Greg**: write the output to stdout in addition to the file (Claude output omitted for brevity) **Greg**: Don't process img tags that already have the alt attribute (Claude output omitted for brevity) **Greg**: Change the program to take a single parameter that is an HTML file. The output will continue to be stdout. Resulting program (untouched by me): ```python import anthropic import base64 import argparse import sys from io import BytesIO from urllib.parse import urljoin from PIL import Image from bs4 import BeautifulSoup import os import requests # Initialize the Anthropic client client = anthropic.Anthropic(api_key="your_api_key_here") def download_image(url): response = requests.get(url) return BytesIO(response.content) def image_to_base64(image): buffered = BytesIO() image.save(buffered, format="PNG") return base64.b64encode(buffered.getvalue()).decode('utf-8') def get_image_type(path): ext = os.path.splitext(path)[1].lower() if ext == '.png': return 'image/png' elif ext in ['.jpg', '.jpeg']: return 'image/jpeg' elif ext == '.gif': return 'image/gif' elif ext == '.webp': return 'image/webp' else: return 'image/jpeg' # Default to JPEG if unable to determine def process_gif(gif_image): if gif_image.is_animated: gif_image.seek(0) return gif_image.convert('RGB') def get_image_description(image_path): try: media_type = get_image_type(image_path) with Image.open(image_path) as img: if media_type == 'image/gif': img = process_gif(img) elif media_type == 'image/webp' and hasattr(img, 'is_animated') and img.is_animated: img.seek(0) if img.mode != 'RGB': img = img.convert('RGB') base64_image = image_to_base64(img) message = client.messages.create( model="claude-3-sonnet-20240229", max_tokens=125, temperature=0.7, system="You are a helpful assistant that provides concise image descriptions.", messages=[ { "role": "user", "content": [ { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": base64_image } }, { "type": "text", "text": "Describe this image in exactly 125 characters. If it's an animated image, describe the first frame." } ] } ] ) return message.content[0].text except Exception as e: return f"Error processing image: {str(e)}" def process_html(html_file): try: with open(html_file, 'r', encoding='utf-8') as file: soup = BeautifulSoup(file, 'html.parser') base_dir = os.path.dirname(os.path.abspath(html_file)) img_tags = soup.find_all('img') for img in img_tags: if img.get('alt'): print(f"Skipping image with existing alt text: {img.get('src')}", file=sys.stderr) continue img_src = img.get('src') if img_src: img_path = os.path.join(base_dir, img_src) if os.path.exists(img_path): print(f"Processing image: {img_path}", file=sys.stderr) description = get_image_description(img_path) img['alt'] = description print(f"Added description: {description}", file=sys.stderr) else: print(f"Image file not found: {img_path}", file=sys.stderr) return soup.prettify() except Exception as e: print(f"Error processing HTML: {str(e)}", file=sys.stderr) return None def main(): parser = argparse.ArgumentParser(description="Generate image descriptions and modify HTML.") parser.add_argument("html_file", help="Path to the HTML file to process") args = parser.parse_args() print(f"Processing HTML file: {args.html_file}", file=sys.stderr) modified_html = process_html(args.html_file) if modified_html: # Print to stdout print(modified_html) else: print("Failed to process the HTML file.", file=sys.stderr) if __name__ == "__main__": main() ``` [#claudeai](/tag/claudeai)[#ai-code-generator](/tag/ai-code-generator) --- # Gmail shortcuts will quadruple your speed Published: 2024-08-15 URL: https://gregwilson.tech/gmail-shortcuts-will-quadruple-your-speed Tags: gmail, mouseless Boost your efficiency with Gmail keyboard shortcuts and handle your inbox like a pro. Learn how to enable and master these shortcuts Recently I’ve been experimenting with using keyboard shortcuts in Gmail to avoid having to remove a hand from the keyboard to reach for the mouse or trackpad. It took some practice, but now it comes naturally and I love the increased efficiency. I tend to like keyboard shortcuts because I’ve spent decades using the vi text editor to write code. [Vi](https://en.wikipedia.org/wiki/Vi?ref=gregsramblings.com) was originally created in the mid-1970s for the Unix operating system before anyone used a mouse or trackpad, so all of the functionality was implemented as keyboard shortcuts. Because your hands never leave the keyboard, programmers using vi tend to be very fast, especially if they master all of the features. As a result, it’s still a popular editor with programmers. ## Turning on Gmail keyboard shortcuts The first thing you need to do is make sure you have keyboard shortcuts turned on. Go to settings , See all settings, then scroll down to keyboard shortcuts and turn them on. ![Screenshot showing how to turn on keyboard shortcuts](/images/inline/gmail-shortcuts-will-quadruple-your-speed/03a9748e-6220-4391-b401-63abeebf992a.webp) ## Press "?" when in Gmail and you'll get this: ![Screenshot of all Gmail shortcuts accessible from inside of Gmail by typing "?"](/images/inline/gmail-shortcuts-will-quadruple-your-speed/ca4db970-e406-4bf3-8f18-d9243408a850.webp) ## Shortcuts I use when viewing my inbox - You’ve probably never noticed it before, but there is a thin blue bar on the left of the first email in your inbox. ![Screenshot showing the selected email indicator](/images/inline/gmail-shortcuts-will-quadruple-your-speed/d5f75f5f-b911-4541-9efb-7203ec71ff9d.webp) - Press the **j** and **k** keys a few times to make the blue bar go up and down the list. - To open the email, press **o** or **enter**. - To select the currently highlighted email, press the **x** key. You’ll see the checkbox beside the email become selected. Once you select one of more emails, you can take action on it to archive, forward, reply, etc. using the shortcuts below. - To search your emails, hit the forward slash **/** ## Shortcuts to use when viewing an individual email conversation If your current workflow is to open an email, take an action, then go back to the inbox to open the next email, using the following shortcuts will save you a lot of time! While looking at an individual email, you can press: - **j** and **k** to go to the older/newer email conversation. - **{** and **}** is similar to **j** and **k** but the current email conversation will be archived before moving to the older/newer one. I tend to use **{** and **}** more frequently because I try to handle each email as I get to it rather than postponing action. ## Taking action on the currently opened email - **r** to start a reply or **a** to start a reply-all and put your cursor in the body so you can immediately start typing. - **f** to forward the email. - **s** to star the email. If you have multiple stars set up — press s multiple times to toggle between different stars. - **;** will expand all emails in the current conversation. When viewing an email conversation with multiple emails, Gmail collapses the emails that you have seen before, which is mostly awesome, but sometimes I want to re-read the entire conversation from the top. This shortcut eliminates the need to expand each email in the conversation one by one. - **!** to mark as spam - **#** to delete - **u** will go back to the inbox - **m** will mute the current conversation — When you mute a conversation, new messages added to the conversation bypass your inbox so that the conversation stays archived. Muted conversations will only pop back into your inbox ready for your attention if a new message in the conversation is addressed to you and no one else, or if you’re added to the “To” or “Cc” line in a new message. - And don’t forget **z** to undo the last action! ## A real-world scenario It’s Tuesday and I’m late to work due to my dog’s vet appointment. When I finally arrive and open up Gmail, I have 53 unread emails. Here’s how I attack it: I open the first email in my main inbox and then start using keyboard shortcuts to work the list. My keystrokes look something like this: **{{{ss{{s{{a(send reply){{a(send reply){;{f(forward email){{{sss{;m{{{r(send reply)s{s{{s{{f(forward email){{{{a(send reply){u** Yes, it looks cryptic, but once you get used to these shortcuts, you’ll be amazed at how fast you can work an inbox. Here’s a breakdown of the first few keystrokes: - **{** archive the email and move to the next one in my inbox - **{** archive the email and move to the next one in my inbox - **{** archive the email and move to the next one in my inbox - **s** star the email - **{** archive the email and move to the next one in my inbox - **{** archive the email and move to the next one in my inbox - **s** star the email - **{** archive the email and move to the next one in my inbox - **{** archive the email and move to the next one in my inbox - **a** reply-all to the current email - **{** archive the email and move to the next one in my inbox - **{** archive the email and move to the next one in my inbox - **a** reply-all to the current email - **{** archive the email and move to the next one in my inbox - **;** expand all emails in the current conversation - **{** archive the email and moved to the next one in my inbox - **f** forward the email - …and so on In this example, I started with the newest email and worked my way back in time (newest to oldest). There are many other shortcuts that I’m not covering in this post, but you can see the full list by pressing “?” in Gmail. [#gmail](/tag/gmail)[#mouseless](/tag/mouseless) --- # Unsend your mistake in Gmail Published: 2024-08-15 URL: https://gregwilson.tech/unsend-your-mistake-in-gmail Tags: gmail, undo Learn how to configure Gmail settings to extend the time window for unsending emails beyond 5 seconds By default, Gmail allows you to unsend an email within 5 seconds of clicking the send button. As soon as you click send, you’ll see a dialog in the bottom-left corner with an “Undo” link. If you’re on your mobile device, you’ll see a similar dialog at the bottom. I found that 5 seconds is a bit fast. I usually don’t realize I’ve accidentally done a reply-all (or worse mistakes) until a few seconds later. Thankfully, Gmail allows you to configure the delay. Go to Settings -> See all settings -> ![](/images/inline/unsend-your-mistake-in-gmail/9d41f761-bab6-4491-8c09-95cd3f821cf3.webp) [#gmail](/tag/gmail)[#undo](/tag/undo) --- # Chrome feature: Mute site Published: 2024-08-15 URL: https://gregwilson.tech/chrome-feature-mute-site Tags: chrome Mute annoying auto-playing content in Chrome with the new site mute feature Some websites insist on auto-playing video/audio content and it drives me nuts. I recently discovered that Chrome now allows you to mute a site. The screenshot below illustrates. ![Screenshot showing how to mute a specific tab in Chrome browser](/images/inline/chrome-feature-mute-site/a9ab3ffe-947b-44d1-94c3-1a5f5f30a742.webp) If content starts playing, you will see a small icon in the tab indicating that it’s muted. [#chrome](/tag/chrome) --- # Flying the new DJI Mavic 3 Pro Published: 2024-08-15 URL: https://gregwilson.tech/flying-the-new-dji-mavic-3-pro Tags: 4k-video, dji, drone, hdr A review of flying the DJI Mavic 3 Pro, showcasing 4k/60fps HDR footage and minor color corrections There are tons of videos of folks raving about the new DJI Mavic 3 Pro. I can confirm that it is indeed awesome. I shot the videos below using the new 3x 70mm camera shot in 4k/60fps HDR (HLG) with some very minor color correction and contrast boost in Final Cut Pro. Make sure it is showing in 4k. Tons of features to explore! [#4k-video](/tag/4k-video)[#dji](/tag/dji)[#drone](/tag/drone)[#hdr](/tag/hdr) --- # Learning about video production and editing from ChatGPT and YouTube Published: 2024-08-15 URL: https://gregwilson.tech/learning-about-video-production-and-editing-from-chatgpt-and-youtube Tags: llm, llms, nikon, chatgpt, gemini, claudeai, final-cut-pro Learn video production and editing with ChatGPT insights and YouTube tutorials. A new way to learn ![](/images/inline/learning-about-video-production-and-editing-from-chatgpt-and-youtube/f350979c-3e06-4c2f-974b-c2fbedfba0e6.webp) I've been an active photographer most of my life, including a few years of doing it professionally (aerial , commercial, wedding, etc.). I recently sold my Nikon F-mount bodies and lenses and upgraded the new Nikon Z-series mirrorless platform. I acquired the new [Nikon Z8](https://www.nikonusa.com/en/nikon-products/product/mirrorless-cameras/z-8.html?ref=gregsramblings.com) and several amazing Nikon Z lenses. The Z8 is an amazing camera for still photography, but it's also stunningly good at video – supporting 8K/60fps, 4K/120fps, in both 8-bit and 10-bit ([full specs](https://www.nikonusa.com/en/nikon-products/product/mirrorless-cameras/z-8.html?ref=gregsramblings.com)). I knew the basics of video and could stumble through simple edits, but I had no idea how to do color grading on log footage, how to export and publish HDR, etc.. As I started to dig in, I quickly learned that it is WAY more complicated than I thought! The more I learned, the further away I felt from being competent! ![Screenshot of Final Cut Pro](/images/inline/learning-about-video-production-and-editing-from-chatgpt-and-youtube/cc52ccd5-8c0d-45af-9d36-32185d07c5b2.webp) I decided to dive deep and found many great YouTube channels that cover these topics (see below), but it was [ChatGPT](https://chat.openai.com/?ref=gregsramblings.com) that helped me fill in the gaps of my knowledge. This is the first time I used an AI chatbot to learn anything significant. Below is my full conversation with ChatGPT (v4) that spanned several days. I was surprised at how efficient this type of learning is. I would watch a couple of YouTube videos then go to ChatGPT to clarify whatever was confusing me. I thought y'all might like to see how it looks to have this type of exchange with an AI bot: [My conversation with ChatGPT about video file formats, HDR, publishing, etc.](https://www.gregsramblings.com/chatgpt-teaches-me-about-video-standards/) I also went to [Google Bard](https://bard.google.com/?ref=gregsramblings.com) when I had a specific question about YouTube: [‎Does youtube support dolby vision?](https://g.co/gemini/share/a802e6738ee1) I even played around with [Anthropic Claude](https://claude.ai/?ref=gregsramblings.com) (doesn't have a sharing feature, so including a screenshot below): ![](/images/inline/learning-about-video-production-and-editing-from-chatgpt-and-youtube/ee2f59e3-1d82-4565-b515-384f16b55282.webp) Below are some great YouTube channels on video editing that I follow - mostly about Apple Final Cut Pro. I chose Final Cut Pro to take the maximum advantage of my Macbook Pro M2 and because it's HDR workflow is nice. Adobe Premier and DaVinci Resolve are fantastic options as well. .webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}.webembed-fallback-card{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal}.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6{font-size:inherit;font-weight:inherit}.webembed-fallback-card blockquote,.webembed-fallback-card dd,.webembed-fallback-card dl,.webembed-fallback-card figure,.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6,.webembed-fallback-card hr,.webembed-fallback-card p{margin:0}.webembed-fallback-card a{color:inherit;text-decoration:none}.webembed-fallback-card button{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}.webembed-fallback-card button{text-transform:none}.webembed-fallback-card \[type=button\],.webembed-fallback-card button{-webkit-appearance:button;background-color:transparent;background-image:none}.webembed-fallback-card \[role=button\],.webembed-fallback-card button{cursor:pointer}.webembed-fallback-card audio,.webembed-fallback-card canvas,.webembed-fallback-card embed,.webembed-fallback-card iframe,.webembed-fallback-card img,.webembed-fallback-card object,.webembed-fallback-card svg,.webembed-fallback-card video{display:block;vertical-align:middle}.webembed-fallback-card img,.webembed-fallback-card video{max-width:100%;height:auto}.webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card ::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card .tw-we-line-clamp-1 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;}.webembed-fallback-card .tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .tw-we-line-clamp-3 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;}.webembed-fallback-card.tw-we-container{width:100%}@media (min-width:640px){.webembed-fallback-card.tw-we-container{max-width:640px}}@media (min-width:768px){.webembed-fallback-card.tw-we-container{max-width:768px}}@media (min-width:1024px){.webembed-fallback-card.tw-we-container{max-width:1024px}}@media (min-width:1280px){.webembed-fallback-card.tw-we-container{max-width:1280px}}@media (min-width:1536px){.webembed-fallback-card.tw-we-container{max-width:1536px}}.webembed-fallback-card .tw-we-col-span-12{grid-column:span 12/span 12}.webembed-fallback-card.tw-we-mx-auto{margin-left:auto;margin-right:auto}.webembed-fallback-card .tw-we-mb-1{margin-bottom:.25rem}.webembed-fallback-card .tw-we-mb-2{margin-bottom:.5rem}.webembed-fallback-card .tw-we-mb-4{margin-bottom:1rem}.webembed-fallback-card .tw-we-mr-0{margin-right:0}.webembed-fallback-card .tw-we-block{display:block}.webembed-fallback-card .tw-we-flex{display:flex}.webembed-fallback-card .tw-we-grid{display:grid}.webembed-fallback-card .tw-we-gap-4{gap:1rem}.webembed-fallback-card .tw-we-h-52{height:13rem}.webembed-fallback-card .tw-we-h-4{height:1rem}.webembed-fallback-card .tw-we-w-full{width:100%}.webembed-fallback-card .tw-we-w-4{width:1rem}.webembed-fallback-card .tw-we-grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.webembed-fallback-card .tw-we-flex-row{flex-direction:row}.webembed-fallback-card .tw-we-flex-col{flex-direction:column}.webembed-fallback-card .tw-we-items-start{align-items:flex-start}.webembed-fallback-card .tw-we-items-center{align-items:center}.webembed-fallback-card .tw-we-items-stretch{align-items:stretch}.webembed-fallback-card .tw-we-justify-between{justify-content:space-between}.webembed-fallback-card .tw-we-overflow-hidden{overflow:hidden}.webembed-fallback-card .tw-we-rounded-xl{border-radius:.75rem}.webembed-fallback-card .tw-we-border{border-width:1px}.webembed-fallback-card .tw-we-border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-bg-cover{background-size:cover}.webembed-fallback-card .tw-we-bg-center{background-position:center}.webembed-fallback-card .tw-we-bg-left{background-position:left}.webembed-fallback-card .tw-we-fill-current{fill:currentColor}.webembed-fallback-card .tw-we-py-10,.webembed-fallback-card.tw-we-py-10{padding-top:2.5rem;padding-bottom:2.5rem;}.webembed-fallback-card .tw-we-py-10{padding-top:4rem;padding-bottom:4rem;}.webembed-fallback-card .tw-we-p-4{padding:1rem;}.webembed-fallback-card .tw-we-py-5{padding-top:1.25rem;padding-bottom:1.25rem}.webembed-fallback-card .tw-we-px-4{padding-left:1rem;padding-right:1rem}.webembed-fallback-card .tw-we-bg-white{background:white}.webembed-fallback-card .tw-we-text-base{font-size:1rem;line-height:1.5rem}.webembed-fallback-card .tw-we-text-lg{font-size:1.125rem;line-height:1.75rem}.webembed-fallback-card .tw-we-font-semibold{font-weight:600}.webembed-fallback-card .tw-we-text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:150ms}.webembed-fallback-card .hover-tw-we-border-slate-300:hover{--tw-border-opacity:1;border-color:rgb(203 213 225 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-underline{text-decoration-line:underline}@media (min-width:768px){.webembed-fallback-card .md-tw-we-flex-row{flex-direction:row}.webembed-fallback-card .md-tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .md-tw-we-mr-4{margin-right:1rem}.webembed-fallback-card .md-tw-we-col-span-4{grid-column:span 4/span 4}.webembed-fallback-card .md-tw-we-col-span-8{grid-column:span 8/span 8}}.webembed-fallback-card .fallback-embed-image-container{height:211px;}@media (min-width:640px){.webembed-fallback-card .fallback-embed-image-container{width:100%;height:240px;}}@media (min-width:768px){.webembed-fallback-card .fallback-embed-image-container{min-width:295px;width:295px;height:199px;}} [ Tyler Stalman Talking about the creative tools we use for our professional photo and video production. www.youtube.com ](https://www.youtube.com/channel/UC6OICk-ceplUJf4sCN3DMnQ) .webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}.webembed-fallback-card{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal}.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6{font-size:inherit;font-weight:inherit}.webembed-fallback-card blockquote,.webembed-fallback-card dd,.webembed-fallback-card dl,.webembed-fallback-card figure,.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6,.webembed-fallback-card hr,.webembed-fallback-card p{margin:0}.webembed-fallback-card a{color:inherit;text-decoration:none}.webembed-fallback-card button{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}.webembed-fallback-card button{text-transform:none}.webembed-fallback-card \[type=button\],.webembed-fallback-card button{-webkit-appearance:button;background-color:transparent;background-image:none}.webembed-fallback-card \[role=button\],.webembed-fallback-card button{cursor:pointer}.webembed-fallback-card audio,.webembed-fallback-card canvas,.webembed-fallback-card embed,.webembed-fallback-card iframe,.webembed-fallback-card img,.webembed-fallback-card object,.webembed-fallback-card svg,.webembed-fallback-card video{display:block;vertical-align:middle}.webembed-fallback-card img,.webembed-fallback-card video{max-width:100%;height:auto}.webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card ::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card .tw-we-line-clamp-1 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;}.webembed-fallback-card .tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .tw-we-line-clamp-3 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;}.webembed-fallback-card.tw-we-container{width:100%}@media (min-width:640px){.webembed-fallback-card.tw-we-container{max-width:640px}}@media (min-width:768px){.webembed-fallback-card.tw-we-container{max-width:768px}}@media (min-width:1024px){.webembed-fallback-card.tw-we-container{max-width:1024px}}@media (min-width:1280px){.webembed-fallback-card.tw-we-container{max-width:1280px}}@media (min-width:1536px){.webembed-fallback-card.tw-we-container{max-width:1536px}}.webembed-fallback-card .tw-we-col-span-12{grid-column:span 12/span 12}.webembed-fallback-card.tw-we-mx-auto{margin-left:auto;margin-right:auto}.webembed-fallback-card .tw-we-mb-1{margin-bottom:.25rem}.webembed-fallback-card .tw-we-mb-2{margin-bottom:.5rem}.webembed-fallback-card .tw-we-mb-4{margin-bottom:1rem}.webembed-fallback-card .tw-we-mr-0{margin-right:0}.webembed-fallback-card .tw-we-block{display:block}.webembed-fallback-card .tw-we-flex{display:flex}.webembed-fallback-card .tw-we-grid{display:grid}.webembed-fallback-card .tw-we-gap-4{gap:1rem}.webembed-fallback-card .tw-we-h-52{height:13rem}.webembed-fallback-card .tw-we-h-4{height:1rem}.webembed-fallback-card .tw-we-w-full{width:100%}.webembed-fallback-card .tw-we-w-4{width:1rem}.webembed-fallback-card .tw-we-grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.webembed-fallback-card .tw-we-flex-row{flex-direction:row}.webembed-fallback-card .tw-we-flex-col{flex-direction:column}.webembed-fallback-card .tw-we-items-start{align-items:flex-start}.webembed-fallback-card .tw-we-items-center{align-items:center}.webembed-fallback-card .tw-we-items-stretch{align-items:stretch}.webembed-fallback-card .tw-we-justify-between{justify-content:space-between}.webembed-fallback-card .tw-we-overflow-hidden{overflow:hidden}.webembed-fallback-card .tw-we-rounded-xl{border-radius:.75rem}.webembed-fallback-card .tw-we-border{border-width:1px}.webembed-fallback-card .tw-we-border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-bg-cover{background-size:cover}.webembed-fallback-card .tw-we-bg-center{background-position:center}.webembed-fallback-card .tw-we-bg-left{background-position:left}.webembed-fallback-card .tw-we-fill-current{fill:currentColor}.webembed-fallback-card .tw-we-py-10,.webembed-fallback-card.tw-we-py-10{padding-top:2.5rem;padding-bottom:2.5rem;}.webembed-fallback-card .tw-we-py-10{padding-top:4rem;padding-bottom:4rem;}.webembed-fallback-card .tw-we-p-4{padding:1rem;}.webembed-fallback-card .tw-we-py-5{padding-top:1.25rem;padding-bottom:1.25rem}.webembed-fallback-card .tw-we-px-4{padding-left:1rem;padding-right:1rem}.webembed-fallback-card .tw-we-bg-white{background:white}.webembed-fallback-card .tw-we-text-base{font-size:1rem;line-height:1.5rem}.webembed-fallback-card .tw-we-text-lg{font-size:1.125rem;line-height:1.75rem}.webembed-fallback-card .tw-we-font-semibold{font-weight:600}.webembed-fallback-card .tw-we-text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:150ms}.webembed-fallback-card .hover-tw-we-border-slate-300:hover{--tw-border-opacity:1;border-color:rgb(203 213 225 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-underline{text-decoration-line:underline}@media (min-width:768px){.webembed-fallback-card .md-tw-we-flex-row{flex-direction:row}.webembed-fallback-card .md-tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .md-tw-we-mr-4{margin-right:1rem}.webembed-fallback-card .md-tw-we-col-span-4{grid-column:span 4/span 4}.webembed-fallback-card .md-tw-we-col-span-8{grid-column:span 8/span 8}}.webembed-fallback-card .fallback-embed-image-container{height:211px;}@media (min-width:640px){.webembed-fallback-card .fallback-embed-image-container{width:100%;height:240px;}}@media (min-width:768px){.webembed-fallback-card .fallback-embed-image-container{min-width:295px;width:295px;height:199px;}} [ Jenn Jager Welcome to my YouTube channel, where I share my video production expertise through tutorials, product reviews, video marketing advice and more! I sta... www.youtube.com ](https://www.youtube.com/channel/UCXBi4sAc9QXfTkhjT0cUrTg) [https://www.youtube.com/@Serge1913?ref=gregsramblings.com](https://www.youtube.com/@Serge1913?ref=gregsramblings.com) .webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}.webembed-fallback-card{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal}.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6{font-size:inherit;font-weight:inherit}.webembed-fallback-card blockquote,.webembed-fallback-card dd,.webembed-fallback-card dl,.webembed-fallback-card figure,.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6,.webembed-fallback-card hr,.webembed-fallback-card p{margin:0}.webembed-fallback-card a{color:inherit;text-decoration:none}.webembed-fallback-card button{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}.webembed-fallback-card button{text-transform:none}.webembed-fallback-card \[type=button\],.webembed-fallback-card button{-webkit-appearance:button;background-color:transparent;background-image:none}.webembed-fallback-card \[role=button\],.webembed-fallback-card button{cursor:pointer}.webembed-fallback-card audio,.webembed-fallback-card canvas,.webembed-fallback-card embed,.webembed-fallback-card iframe,.webembed-fallback-card img,.webembed-fallback-card object,.webembed-fallback-card svg,.webembed-fallback-card video{display:block;vertical-align:middle}.webembed-fallback-card img,.webembed-fallback-card video{max-width:100%;height:auto}.webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card ::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card .tw-we-line-clamp-1 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;}.webembed-fallback-card .tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .tw-we-line-clamp-3 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;}.webembed-fallback-card.tw-we-container{width:100%}@media (min-width:640px){.webembed-fallback-card.tw-we-container{max-width:640px}}@media (min-width:768px){.webembed-fallback-card.tw-we-container{max-width:768px}}@media (min-width:1024px){.webembed-fallback-card.tw-we-container{max-width:1024px}}@media (min-width:1280px){.webembed-fallback-card.tw-we-container{max-width:1280px}}@media (min-width:1536px){.webembed-fallback-card.tw-we-container{max-width:1536px}}.webembed-fallback-card .tw-we-col-span-12{grid-column:span 12/span 12}.webembed-fallback-card.tw-we-mx-auto{margin-left:auto;margin-right:auto}.webembed-fallback-card .tw-we-mb-1{margin-bottom:.25rem}.webembed-fallback-card .tw-we-mb-2{margin-bottom:.5rem}.webembed-fallback-card .tw-we-mb-4{margin-bottom:1rem}.webembed-fallback-card .tw-we-mr-0{margin-right:0}.webembed-fallback-card .tw-we-block{display:block}.webembed-fallback-card .tw-we-flex{display:flex}.webembed-fallback-card .tw-we-grid{display:grid}.webembed-fallback-card .tw-we-gap-4{gap:1rem}.webembed-fallback-card .tw-we-h-52{height:13rem}.webembed-fallback-card .tw-we-h-4{height:1rem}.webembed-fallback-card .tw-we-w-full{width:100%}.webembed-fallback-card .tw-we-w-4{width:1rem}.webembed-fallback-card .tw-we-grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.webembed-fallback-card .tw-we-flex-row{flex-direction:row}.webembed-fallback-card .tw-we-flex-col{flex-direction:column}.webembed-fallback-card .tw-we-items-start{align-items:flex-start}.webembed-fallback-card .tw-we-items-center{align-items:center}.webembed-fallback-card .tw-we-items-stretch{align-items:stretch}.webembed-fallback-card .tw-we-justify-between{justify-content:space-between}.webembed-fallback-card .tw-we-overflow-hidden{overflow:hidden}.webembed-fallback-card .tw-we-rounded-xl{border-radius:.75rem}.webembed-fallback-card .tw-we-border{border-width:1px}.webembed-fallback-card .tw-we-border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-bg-cover{background-size:cover}.webembed-fallback-card .tw-we-bg-center{background-position:center}.webembed-fallback-card .tw-we-bg-left{background-position:left}.webembed-fallback-card .tw-we-fill-current{fill:currentColor}.webembed-fallback-card .tw-we-py-10,.webembed-fallback-card.tw-we-py-10{padding-top:2.5rem;padding-bottom:2.5rem;}.webembed-fallback-card .tw-we-py-10{padding-top:4rem;padding-bottom:4rem;}.webembed-fallback-card .tw-we-p-4{padding:1rem;}.webembed-fallback-card .tw-we-py-5{padding-top:1.25rem;padding-bottom:1.25rem}.webembed-fallback-card .tw-we-px-4{padding-left:1rem;padding-right:1rem}.webembed-fallback-card .tw-we-bg-white{background:white}.webembed-fallback-card .tw-we-text-base{font-size:1rem;line-height:1.5rem}.webembed-fallback-card .tw-we-text-lg{font-size:1.125rem;line-height:1.75rem}.webembed-fallback-card .tw-we-font-semibold{font-weight:600}.webembed-fallback-card .tw-we-text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:150ms}.webembed-fallback-card .hover-tw-we-border-slate-300:hover{--tw-border-opacity:1;border-color:rgb(203 213 225 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-underline{text-decoration-line:underline}@media (min-width:768px){.webembed-fallback-card .md-tw-we-flex-row{flex-direction:row}.webembed-fallback-card .md-tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .md-tw-we-mr-4{margin-right:1rem}.webembed-fallback-card .md-tw-we-col-span-4{grid-column:span 4/span 4}.webembed-fallback-card .md-tw-we-col-span-8{grid-column:span 8/span 8}}.webembed-fallback-card .fallback-embed-image-container{height:211px;}@media (min-width:640px){.webembed-fallback-card .fallback-embed-image-container{width:100%;height:240px;}}@media (min-width:768px){.webembed-fallback-card .fallback-embed-image-container{min-width:295px;width:295px;height:199px;}} [ Tamara Gabriel I test camera gear in real-world scenarios, sharing my experiences, thoughts, and comparisons with gear for vloggers using action cameras and pocket c... www.youtube.com ](https://www.youtube.com/channel/UCC-T6bIJfxiBz0V9GdQJDOA) .webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}.webembed-fallback-card{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-feature-settings:normal}.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6{font-size:inherit;font-weight:inherit}.webembed-fallback-card blockquote,.webembed-fallback-card dd,.webembed-fallback-card dl,.webembed-fallback-card figure,.webembed-fallback-card h1,.webembed-fallback-card h2,.webembed-fallback-card h3,.webembed-fallback-card h4,.webembed-fallback-card h5,.webembed-fallback-card h6,.webembed-fallback-card hr,.webembed-fallback-card p{margin:0}.webembed-fallback-card a{color:inherit;text-decoration:none}.webembed-fallback-card button{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}.webembed-fallback-card button{text-transform:none}.webembed-fallback-card \[type=button\],.webembed-fallback-card button{-webkit-appearance:button;background-color:transparent;background-image:none}.webembed-fallback-card \[role=button\],.webembed-fallback-card button{cursor:pointer}.webembed-fallback-card audio,.webembed-fallback-card canvas,.webembed-fallback-card embed,.webembed-fallback-card iframe,.webembed-fallback-card img,.webembed-fallback-card object,.webembed-fallback-card svg,.webembed-fallback-card video{display:block;vertical-align:middle}.webembed-fallback-card img,.webembed-fallback-card video{max-width:100%;height:auto}.webembed-fallback-card \*,.webembed-fallback-card ::after,.webembed-fallback-card ::before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card ::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scroll-snap-strictness:proximity;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.webembed-fallback-card .tw-we-line-clamp-1 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;}.webembed-fallback-card .tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .tw-we-line-clamp-3 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;}.webembed-fallback-card.tw-we-container{width:100%}@media (min-width:640px){.webembed-fallback-card.tw-we-container{max-width:640px}}@media (min-width:768px){.webembed-fallback-card.tw-we-container{max-width:768px}}@media (min-width:1024px){.webembed-fallback-card.tw-we-container{max-width:1024px}}@media (min-width:1280px){.webembed-fallback-card.tw-we-container{max-width:1280px}}@media (min-width:1536px){.webembed-fallback-card.tw-we-container{max-width:1536px}}.webembed-fallback-card .tw-we-col-span-12{grid-column:span 12/span 12}.webembed-fallback-card.tw-we-mx-auto{margin-left:auto;margin-right:auto}.webembed-fallback-card .tw-we-mb-1{margin-bottom:.25rem}.webembed-fallback-card .tw-we-mb-2{margin-bottom:.5rem}.webembed-fallback-card .tw-we-mb-4{margin-bottom:1rem}.webembed-fallback-card .tw-we-mr-0{margin-right:0}.webembed-fallback-card .tw-we-block{display:block}.webembed-fallback-card .tw-we-flex{display:flex}.webembed-fallback-card .tw-we-grid{display:grid}.webembed-fallback-card .tw-we-gap-4{gap:1rem}.webembed-fallback-card .tw-we-h-52{height:13rem}.webembed-fallback-card .tw-we-h-4{height:1rem}.webembed-fallback-card .tw-we-w-full{width:100%}.webembed-fallback-card .tw-we-w-4{width:1rem}.webembed-fallback-card .tw-we-grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.webembed-fallback-card .tw-we-flex-row{flex-direction:row}.webembed-fallback-card .tw-we-flex-col{flex-direction:column}.webembed-fallback-card .tw-we-items-start{align-items:flex-start}.webembed-fallback-card .tw-we-items-center{align-items:center}.webembed-fallback-card .tw-we-items-stretch{align-items:stretch}.webembed-fallback-card .tw-we-justify-between{justify-content:space-between}.webembed-fallback-card .tw-we-overflow-hidden{overflow:hidden}.webembed-fallback-card .tw-we-rounded-xl{border-radius:.75rem}.webembed-fallback-card .tw-we-border{border-width:1px}.webembed-fallback-card .tw-we-border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-bg-cover{background-size:cover}.webembed-fallback-card .tw-we-bg-center{background-position:center}.webembed-fallback-card .tw-we-bg-left{background-position:left}.webembed-fallback-card .tw-we-fill-current{fill:currentColor}.webembed-fallback-card .tw-we-py-10,.webembed-fallback-card.tw-we-py-10{padding-top:2.5rem;padding-bottom:2.5rem;}.webembed-fallback-card .tw-we-py-10{padding-top:4rem;padding-bottom:4rem;}.webembed-fallback-card .tw-we-p-4{padding:1rem;}.webembed-fallback-card .tw-we-py-5{padding-top:1.25rem;padding-bottom:1.25rem}.webembed-fallback-card .tw-we-px-4{padding-left:1rem;padding-right:1rem}.webembed-fallback-card .tw-we-bg-white{background:white}.webembed-fallback-card .tw-we-text-base{font-size:1rem;line-height:1.5rem}.webembed-fallback-card .tw-we-text-lg{font-size:1.125rem;line-height:1.75rem}.webembed-fallback-card .tw-we-font-semibold{font-weight:600}.webembed-fallback-card .tw-we-text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:150ms}.webembed-fallback-card .hover-tw-we-border-slate-300:hover{--tw-border-opacity:1;border-color:rgb(203 213 225 / var(--tw-border-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235 / var(--tw-text-opacity))}.webembed-fallback-card .tw-we-group:hover .group-hover:tw-we-underline{text-decoration-line:underline}@media (min-width:768px){.webembed-fallback-card .md-tw-we-flex-row{flex-direction:row}.webembed-fallback-card .md-tw-we-line-clamp-2 {overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;}.webembed-fallback-card .md-tw-we-mr-4{margin-right:1rem}.webembed-fallback-card .md-tw-we-col-span-4{grid-column:span 4/span 4}.webembed-fallback-card .md-tw-we-col-span-8{grid-column:span 8/span 8}}.webembed-fallback-card .fallback-embed-image-container{height:211px;}@media (min-width:640px){.webembed-fallback-card .fallback-embed-image-container{width:100%;height:240px;}}@media (min-width:768px){.webembed-fallback-card .fallback-embed-image-container{min-width:295px;width:295px;height:199px;}} [ Dylan Bates • The Final Cut Bro Final Cut Pro and Apple Motion Tutorials Does anybody actually read these descriptions? You get 10 \*points if you do! \*points have no actual value. T... www.youtube.com ](https://www.youtube.com/channel/UCYlZLHOzom9-MryCEodaoXg) I still have a lot to learn, but I'm at least able to create something cool. For example: Lastly, I'll point out that through this entire learning exercise, I never once looked at the docs for Final Cut Pro, which is telling. Obviously those docs were part of the training content for ChatGPT, Bard, Claude, etc., but I never felt the need to go to the source. :) [#llm](/tag/llm)[#llms](/tag/llms)[#nikon](/tag/nikon)[#chatgpt](/tag/chatgpt)[#gemini](/tag/gemini)[#claudeai](/tag/claudeai)[#final-cut-pro](/tag/final-cut-pro) --- # Play Chess via Messaging Apps Published: 2022-07-30 URL: https://gregwilson.tech/play-chess-via-messaging-apps Tags: chess, chessboardjs, chess-online Create a serverless service to play chess via messaging apps using FEN notation and chessboard.js. Scalable, no backend data store needed While watching [The Queen’s Gambit](https://www.netflix.com/title/80234304?ref=gregsramblings.com), I was reminded about how much I used to enjoy playing chess. I was eager to play a game, so I started to tweet, “D2-D4,” knowing that some of my Twitter followers would recognize this as an opening move and likely respond with their move, giving me the fix I needed. I paused before hitting the tweet button because I realized that I’d need to set up a board (physical or virtual) to keep track of the game. If I received multiple responses, I’d need multiple boards. I decided not to send the tweet. Later in the day, I had the idea to create a simple service that enables my use case. Instead of designing yet another chess site (I [built one](https://www.youtube.com/watch?v=dP69x3Bg-mY&ref=gregsramblings.com) with a friend in 2009 that is long since gone), I decided to create a chessboard logger and visualizer to make it practical to play via Twitter or any other messaging/social platform. I didn’t have a lot of time, so I was optimizing for simplicity. Instead of tweeting moves back and forth, players tweet links back and forth, and those links go to a site that renders the current chessboard, allows a new move, and creates a new link to paste back to the opponent. I wanted this to be 100% serverless, meaning that it will scale to zero and have zero maintenance requirements. Excited about this idea, I put together a shopping list: **My MVP requirements:** - Represent the board position—ideally completely in the URL to keep it stateless from a server-side perspective. - Display a chessboard and let the player make their next move. **Stretch goals:** - Enforce chess rules (allow only legal moves). - Dynamically create a png/jpg of the chessboard that I can use as an [Open Graph](https://ogp.me/?ref=gregsramblings.com) and [Twitter card](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/summary-card-with-large-image?ref=gregsramblings.com) image so that when a player sends the link, the image of the board will automatically display. ## Putting it all together ### Representing the board position There is a standard notation for describing a particular board position of a chess game called [Forsyth–Edwards Notation (FEN)](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation?ref=gregsramblings.com) that was exactly what I needed. A FEN is a sequence of ASCII characters. For example, the starting position for any chess game can be represented by the following string: `rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1` Each letter is a piece: pawn = "P", knight = "N", bishop = "B", rook = "R", queen = "Q" and king = "K". Uppercase letters represent white pieces and lowercase letters represent black. The last part of the string is specific to certain rules in chess ([read more about FEN](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation?ref=gregsramblings.com)). I knew I could use this in the URL, so my first requirement was complete and I was able to represent the board state in the URL, eliminating the need for a backend data store. ### Displaying the chessboard and allowing drag-and-drop moves Numerous chess libraries are available. One in particular that caught my eye was [chessboard.js](https://github.com/oakmac/chessboardjs/?ref=gregsramblings.com)—described as “**a JavaScript chessboard component with a flexible ‘just a board’ API**”. I quickly discovered that this library can display chessboards from a FEN, allow pieces to be moved, and update the FEN. Perfect! In only two hours, I had the basic functionality implemented. ### Enforcing chess rules I originally thought that making this service aware of chess rules would be difficult, but then I saw the [example in the chessboard.js docs](https://chessboardjs.com/examples?ref=gregsramblings.com#5000) showing how to integrate it with another library called [chess.js](https://github.com/jhlywa/chess.js?ref=gregsramblings.com)—“**a JavaScript chess library that is used for chess move generation/validation, piece placement/movement, and check/checkmate/stalemate detection—basically everything but the AI**”. A short time later, I had it working! Stretch goal #1 completed. Here’s what a couple of game moves look like: Moving the pawn from D2 to D4 in a new game—[https://chessmsgs.com/?fen=rnbqkbnr%2Fpppppppp%2F8%2F8%2F3P4%2F8%2FPPP1PPPP%2FRNBQKBNR+b+KQkq+d3+0+1&to=d4&from=d2&gid=mOhlhRlMboYsHLqBF1f7I](https://chessmsgs.com/?fen=rnbqkbnr%2Fpppppppp%2F8%2F8%2F3P4%2F8%2FPPP1PPPP%2FRNBQKBNR+b+KQkq+d3+0+1&to=d4&from=d2&gid=mOhlhRlMboYsHLqBF1f7I&ref=gregsramblings.com) Black countering with a similar move of pawn from D7 to D5—[https://chessmsgs.com/?fen=rnbqkbnr%2Fppp1pppp%2F8%2F3p4%2F3P4%2F8%2FPPP1PPPP%2FRNBQKBNR+w+KQkq+d6+0+2&to=d5&from=d7&gid=mOhlhRlMboYsHLqBF1f7I](https://chessmsgs.com/?fen=rnbqkbnr%2Fppp1pppp%2F8%2F3p4%2F3P4%2F8%2FPPP1PPPP%2FRNBQKBNR+w+KQkq+d6+0+2&to=d5&from=d7&gid=mOhlhRlMboYsHLqBF1f7I&ref=gregsramblings.com) The URL has the following data: - **fen**—the new board position - **from** and **to**—indicating what move occurred (I use this to highlight the squares) - **gid**—a unique game ID (I used [nanoid](https://www.npmjs.com/package/nanoid?ref=gregsramblings.com))—I’ll use this to connect moves to a single game in the future (for example, I could add a feature that lets the user request the entire game transcript). ![screenshot of chessmsgs.com](/images/inline/play-chess-via-messaging-apps/d76394bd-c580-4bae-9620-1c3fa387e19c.webp) ### Done! Except... At this point, there were no server requirements other than simple HTML static hosting. But after playing it with some friends and family, I decided that I really wanted to accomplish the other stretch goal—dynamically create a png/jpg of the chessboard that I can use as an Open Graph and Twitter card image. With this capability, an image of the board will automatically display when a player sends the link. Without it, the game is a series of ugly URLs. ### Dynamically creating the Open Graph image This requirement introduced some server-side requirements. I needed two things to happen on the server. First, I needed to dynamically generate a board image from a FEN. Once again, open source to the rescue (almost). I found [chess-image-generator](https://github.com/andyruwruw/chess-image-generator?ref=gregsramblings.com), a JavaScript library that creates a png from a [FEN](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation?ref=gregsramblings.com). I wrapped this in a bit of Node.js/Express code so that I could access the image as if it were static. For example, here’s a demo of the real endpoint: [https://chessmsgs.com/fenimg/v1/rnbqkb1r/ppp1pppp/5n2/3p4/3P4/2N5/PPP1PPPP/R1BQKBNR w KQkq - 2 3.png](https://chessmsgs.com/fenimg/v1/rnbqkb1r/ppp1pppp/5n2/3p4/3P4/2N5/PPP1PPPP/R1BQKBNR%20w%20KQkq%20-%202%203.png?ref=gregsramblings.com). This link results in this image: ![Dynamically created chessboard in chessmsgs.com](/images/inline/play-chess-via-messaging-apps/00c4a1a5-b5e5-486f-8910-998b088d5130.webp) Second, I needed to dynamically inject this FEN-embedded URL into the content attribute of the meta tag in the main HTML. Like me, you might be thinking that you could just do some DOM manipulation in JavaScript and avoid having to dynamically change HTML on the server. But the Open Graph image is retrieved by a bot from whatever service you use for messaging. These bots don’t execute any client-side JavaScript and expect all values to be static. So that led to additional server-side work. I needed to dynamically convert this: ```xml ``` Into something like this: ```xml ``` I could have used one of many Node templating engines to do this, but they all seemed like overkill for this simple substitution requirement, so I just wrote a few lines of code for some string.replace() calls in my Node server. With this functionality added, a game on Twitter (and other services) now looks much better: ![](/images/inline/play-chess-via-messaging-apps/0c0bf913-a1f6-43a6-830b-cb29176f5b5d.webp) ### Check out the code The source for chessmsgs.com is available on GitHub at [https://github.com/gregsramblings/chessmsgs](https://github.com/gregsramblings/chessmsgs?ref=gregsramblings.com). ### What’s next? If I really wanted to engineer this for extreme loads, I could easily deploy it to multiple regions throughout the globe and set up a load balancer and possibly a CDN. When I first started thinking about the image generation, I naturally thought about caching many common images in object storage. This would be easy to do and storage is crazy cheap. But then I did a bit of research and learned the following fun facts. After two moves (one move for each player), there are 400 distinct board positions. After each player moves again (two moves each), this number is now 71,782 distinct positions. After each player moves again (three moves each), the number is now 9,132,484 distinct positions! I could gain a bit of performance by caching the most popular openings, but each game would quickly go beyond the cached images so it didn’t seem worth it. By the way, to cache every possible board position would be about 10⁴⁶ positions, which is a **massive** number that doesn’t even have a name. ### Conclusion This was a fun project—almost therapeutic for me since my “day job” doesn’t allow much time for writing code. I just need more time!