<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>chelsea hopkins · field notes</title>
  <link href="https://chelseahopkins.co.uk/feed.xml" rel="self"/>
  <link href="https://chelseahopkins.co.uk/"/>
  <id>https://chelseahopkins.co.uk/</id>
  <updated>2026-09-10T00:00:00Z</updated>
  <author><name>chelsea hopkins</name></author>
  <entry>
    <title>a human veto on ai social posts</title>
    <link href="https://chelseahopkins.co.uk/notes/a-human-veto-on-ai-social-posts/"/>
    <id>https://chelseahopkins.co.uk/notes/a-human-veto-on-ai-social-posts/</id>
    <updated>2026-09-10T00:00:00Z</updated>
    <summary>how jackdaw drafts in your voice, waits for you, and publishes to ten platforms without a single platform adapter.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://heyjackdaw.com&quot;&gt;jackdaw&lt;/a&gt; drafts social posts, shows them to you, and does nothing until you say so. nobody’s coming to run your socials, so it does the writing. you keep the veto. this is how that’s built, including the two places where the neat version of the story isn’t quite true.&lt;/p&gt;
&lt;h2&gt;the veto is a field, not a flag&lt;/h2&gt;
&lt;p&gt;i expected to build a status column. draft, approved, published. instead the whole approval model is one nullable field on a proposal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;/** ISO time after which the proposal auto-queues; null = manual approval only. */
deadline: string | null;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;null means a human has to act. a timestamp means a human has until then. that’s it. everything else is which list the thing sits in.&lt;/p&gt;
&lt;p&gt;there’s a dial for how much review sits between drafting and the queue: hands-on, batch, veto, full. on hands-on, the nightly run does nothing at all and every draft waits for a tap. on veto and full, a proposal gets a window, twelve hours by default, and if no objection is heard it queues itself. so at those settings the veto is a window, not a gate, and the notification becomes the safety mechanism. the code says so in a comment i left for myself: it’s the most important message the engine sends, and it must not depend on a channel the tenant might not have. telegram if you’ve got it, email if you haven’t.&lt;/p&gt;
&lt;h2&gt;nothing is ever told to publish now&lt;/h2&gt;
&lt;p&gt;the publishing api offers four modes: draft, queue, schedule, publish now. the application only ever constructs two of them. every path that creates a post builds either a draft or a queue entry, and the queue publishes at a slot time. “publish now” exists in the client’s type definitions and nowhere else. i’d rather not be able to write the dangerous call than remember not to.&lt;/p&gt;
&lt;p&gt;approval covers pixels too. for carousels, claude only writes the words and picks a layout. you see the copy before anything renders, and you can’t approve a slide you haven’t seen.&lt;/p&gt;
&lt;h2&gt;ten platforms, zero platform adapters&lt;/h2&gt;
&lt;p&gt;instagram, tiktok, twitter, threads, bluesky, linkedin, facebook, reddit, youtube, pinterest. i assumed that meant ten adapter files. there are none. publishing goes through one vendor client, and the difference between platforms lives in three places: a prose note per platform that the model reads while drafting, a short list of which platforms require media, and a per-target override at post time.&lt;/p&gt;
&lt;p&gt;the prose is the real logic. twitter’s note is about the 280 characters including hashtags. linkedin’s is the longest by far, and includes the instruction not to write a stack of one-line paragraphs, which is the most recognisable ai shape on that platform. the only platform that gets an actual branch in code is linkedin, because swipeable carousels there have to be pdf documents rather than images.&lt;/p&gt;
&lt;h2&gt;retries, honestly&lt;/h2&gt;
&lt;p&gt;every create call carries a fresh request id in a header, and the vendor treats a replay of the same id as the same post. the response can come back as a new post or an existing one, and the code handles both the same way. behind that, the vendor also rejects identical content to the same account within a day. so a network retry can’t double-post. a person tapping the button twice in quick succession is caught by the second defence, not the first. i’d rather say that plainly than claim more than the header does.&lt;/p&gt;
&lt;h2&gt;what “learns what lands” actually means&lt;/h2&gt;
&lt;p&gt;three loops, all ending in the same system prompt.&lt;/p&gt;
&lt;p&gt;engagement comes back from published posts. after every ten new posts, or thirty days, a report runs: median engagement per platform, with and without media. claude turns that into at most five bullets, and those bullets ride in every subsequent draft. under twenty posts the block labels itself as early hints to be treated as weak signals, not rules, so the model doesn’t over-fit to a fortnight.&lt;/p&gt;
&lt;p&gt;corrections can be pinned. when you refine a draft in telegram, one tap turns that refinement into a standing rule, and standing rules override everything, including the tone profile. one of them is enforced in code as well as in prompt: if you’ve ever said no hashtags, a regex strips them after generation, because the model drifts.&lt;/p&gt;
&lt;p&gt;facts about the business are kept separately from voice, and capped at forty, because the whole list rides in every prompt and would otherwise quietly become the largest cost per draft.&lt;/p&gt;
&lt;p&gt;and the honest gap: rejections teach nothing. skipping a proposal deletes it. the system learns from what the audience did with what you published, not from which drafts you chose over which. that’s a later problem.&lt;/p&gt;
&lt;h2&gt;small things i’m fond of&lt;/h2&gt;
&lt;p&gt;the base prompt is fifty lines of rules against sounding like a machine, grouped by how they fail. one test: read the draft without its first line and without its last line, and if it reads better, cut them. another: repeat a noun rather than reaching for a synonym, because swapping words to avoid repetition is a machine habit.&lt;/p&gt;
&lt;p&gt;em dashes are stripped in code after generation, everywhere, including headlines in the renderer.&lt;/p&gt;
&lt;p&gt;when the model answers in prose instead of json, usually to ask for more context, that isn’t an error. it’s shown as jackdaw talking.&lt;/p&gt;
&lt;p&gt;every drafting path claims an idea from your monthly allowance up front and refunds it if nothing was produced. nobody should lose an idea to somebody else’s outage.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>getting bullied into exercise by claude</title>
    <link href="https://chelseahopkins.co.uk/notes/getting-bullied-into-exercise-by-claude/"/>
    <id>https://chelseahopkins.co.uk/notes/getting-bullied-into-exercise-by-claude/</id>
    <updated>2026-09-10T00:00:00Z</updated>
    <summary>a claude code skill is a markdown file. this one makes claude set you a desk workout while it works, then check up on you.</summary>
    <content type="html">&lt;p&gt;i sit down to write code and stand up several hours later. &lt;a href=&quot;https://github.com/ookpassant/minimum-viable-exercise&quot;&gt;minimum-viable-exercise&lt;/a&gt; is a claude code skill that makes the tool i’m sitting in front of do something about that.&lt;/p&gt;
&lt;h2&gt;what a skill actually is&lt;/h2&gt;
&lt;p&gt;a skill is a folder with a markdown file in it. the file has a short header that tells claude when the skill applies, and then plain instructions. no code, no api. you install it and claude reads it whenever the description matches what’s about to happen.&lt;/p&gt;
&lt;p&gt;the trick in this one is the trigger. it fires before any task claude thinks will take a while: long code, documents, research. the header says so in words. “if there’s a delay coming, use it.”&lt;/p&gt;
&lt;h2&gt;what it does&lt;/h2&gt;
&lt;p&gt;before starting the work, claude names one exercise, gives a rep count or a duration, adds a form cue, and one dry line. then it gets on with the job without waiting for you. when it comes back with the result, it asks whether you did it. once.&lt;/p&gt;
&lt;p&gt;the tone is the point, and the file is explicit about it. dry, deadpan, mildly competitive. not cheerful. not optional-feeling. “the exercise is happening. the only variable is whether the user completes it.”&lt;/p&gt;
&lt;p&gt;there’s a bank of twenty exercises, all doable at a desk in under ninety seconds. seated leg raises. desk push-ups. glute squeezes, “completely invisible. zero excuses.” star jumps, if nobody’s watching.&lt;/p&gt;
&lt;h2&gt;the accountability check&lt;/h2&gt;
&lt;p&gt;this is the whole bit, and the file says so. if you did it, one dry acknowledgement. if you didn’t, one line of mild judgement, then the work anyway. if you ignore the question: “i’ll take the silence as a no.”&lt;/p&gt;
&lt;h2&gt;what i learned writing it&lt;/h2&gt;
&lt;p&gt;most of the file is about what not to do. don’t give more than one exercise. don’t moralise past a single line. don’t be coach-y. don’t skip the check. writing a good skill turned out to be writing a good brief, which is the part of my job i already knew how to do.&lt;/p&gt;
&lt;p&gt;it’s free, it’s on github, and it will make your assistant slightly disappointed in you.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>how whimsee knows you're there</title>
    <link href="https://chelseahopkins.co.uk/notes/how-whimsee-knows-youre-there/"/>
    <id>https://chelseahopkins.co.uk/notes/how-whimsee-knows-youre-there/</id>
    <updated>2026-09-10T00:00:00Z</updated>
    <summary>a postgis distance check inside one database function, and a table that nobody is allowed to write to.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://whimsee.co.uk&quot;&gt;whimsee&lt;/a&gt; is an app for hiding a small note where you stand. someone else can only read it by walking to the same spot. that sentence is the whole product, and it lives or dies on one question: how does the server know you’re actually there?&lt;/p&gt;
&lt;p&gt;the short answer is that it doesn’t trust the phone. it measures.&lt;/p&gt;
&lt;h2&gt;what a note is, to the database&lt;/h2&gt;
&lt;p&gt;in the app the word is glimmer. in the database the table is called &lt;code&gt;treasures&lt;/code&gt;, because the table came first. it stores latitude and longitude as plain numbers, and postgres derives a geography point from them as a generated column:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;location GEOGRAPHY(POINT, 4326) GENERATED ALWAYS AS (
  ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geography
) STORED,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;geography rather than geometry matters. distances come back in metres on a spheroid, so every threshold in the system is a literal number of metres and there is no projection maths anywhere. a gist index sits on the column, so “what’s near me” is cheap.&lt;/p&gt;
&lt;h2&gt;the map shows you nothing&lt;/h2&gt;
&lt;p&gt;when the app draws nearby glimmers it calls one function, &lt;code&gt;get_treasures_nearby()&lt;/code&gt;. it returns an id, a position, a distance, and whether you’ve already found it. it does not return the note and it does not return who left it. the mystery is enforced at the query, not in the interface.&lt;/p&gt;
&lt;h2&gt;the gate&lt;/h2&gt;
&lt;p&gt;to open one, the phone calls &lt;code&gt;discover_treasure()&lt;/code&gt; with three values: the glimmer id, your latitude, your longitude. inside, one line decides everything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;IF NOT ST_DWithin(
  v_treasure.location,
  ST_SetSRID(ST_MakePoint(user_lon, user_lat), 4326)::geography,
  15
) THEN
  RAISE EXCEPTION 'Not close enough yet — keep wandering';
END IF;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;fifteen metres. the app itself celebrates at ten. the slack points outward on purpose. gps under tree cover drifts, and the failure i wanted to engineer out was standing on the thing while the server says no. every dig the phone offers is one the server will honour, and the extra five metres is the price.&lt;/p&gt;
&lt;h2&gt;why you can’t skip the gate&lt;/h2&gt;
&lt;p&gt;this is the part i’m proudest of, and it isn’t clever code. it’s the absence of a permission.&lt;/p&gt;
&lt;p&gt;row level security on &lt;code&gt;treasures&lt;/code&gt; says you can read a row if you created it, or if there’s a row in &lt;code&gt;discoveries&lt;/code&gt; saying you found it. and &lt;code&gt;discoveries&lt;/code&gt; has no insert policy at all. no client can write to it. the only thing that can is &lt;code&gt;discover_treasure()&lt;/code&gt;, which runs with elevated rights and only writes after the distance check passes.&lt;/p&gt;
&lt;p&gt;so the content isn’t hidden behind a check you could route around. the only way to get the key is to call the function that measures you.&lt;/p&gt;
&lt;h2&gt;the honest limits&lt;/h2&gt;
&lt;p&gt;there is no anti-spoofing. no mock-location detection, no impossible-travel check. gps accuracy never leaves the phone. it’s used for the feel of the hunt, where fixes worse than 35 metres are ignored, until an app review ipad with no gps chip taught me to relax that after eight seconds. the server never sees it. if you fake your position to read a stranger’s note, you’ve spent real effort defeating the point of a free app about going outside. that’s the bet.&lt;/p&gt;
&lt;p&gt;offline is the interesting tension. you can pack a five-kilometre bundle onto your phone for dead zones, which means the content travels with you. it’s checked locally at fifteen metres and rechecked on replay at twenty-five. i know that weakens the guarantee. children’s bundles are family-only for exactly that reason.&lt;/p&gt;
&lt;h2&gt;the bit i didn’t expect&lt;/h2&gt;
&lt;p&gt;row level security filters rows, not columns. a security review in july found that the table for permanent spots had a permissive select policy, so anyone with the public key could read a spot’s hidden description and exact pin straight from the table, skipping the visit gate entirely. glimmers were never exposed that way because they were never served from their table. the fix was to revoke select on spots and route everything through the same kind of function.&lt;/p&gt;
&lt;p&gt;the lesson generalises. don’t serve the secret table at all.&lt;/p&gt;
&lt;h2&gt;the one thing that leaves the phone&lt;/h2&gt;
&lt;p&gt;for analytics, the only location signal that ever leaves the device is a precision-five geohash, a cell about five kilometres across. glimmer content and exact coordinates never do.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>wiring posthog into wordpress properly</title>
    <link href="https://chelseahopkins.co.uk/notes/wiring-posthog-into-wordpress-properly/"/>
    <id>https://chelseahopkins.co.uk/notes/wiring-posthog-into-wordpress-properly/</id>
    <updated>2026-09-10T00:00:00Z</updated>
    <summary>consent that actually gates, woocommerce events that don't double-count, a first-party proxy that can't be abused, and analytics inside wp-admin.</summary>
    <content type="html">&lt;p&gt;i found posthog this year, building &lt;a href=&quot;https://whimsee.co.uk&quot;&gt;whimsee&lt;/a&gt;, and liked it enough that i wanted it on the wordpress sites i look after too. the existing options were a snippet pasted into a theme, which works until someone changes the theme, or asks about cookies, or wants to know what the site did last week without logging into a second dashboard. so i wrote &lt;a href=&quot;https://github.com/ookpassant/pressed-hog&quot;&gt;pressed-hog&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;it’s a hedgehog with a wordpress plugin in it. here is what it turned out to need.&lt;/p&gt;
&lt;h2&gt;a wizard, because settings pages get ignored&lt;/h2&gt;
&lt;p&gt;activation opens a four-step setup: pick your region, paste your project key, choose tracking and consent, send a test event. the key is validated live, from the server, against your posthog host, so you find out it’s wrong before you’ve forgotten which tab you copied it from. everything the wizard sets is editable later in a normal settings page. the wizard exists because nobody reads the normal settings page.&lt;/p&gt;
&lt;h2&gt;consent that gates, not decorates&lt;/h2&gt;
&lt;p&gt;three modes. none: track immediately. a built-in banner: posthog starts with &lt;code&gt;opt_out_capturing_by_default&lt;/code&gt; set, and nothing is captured until someone accepts. external: bring your own consent plugin, and tracking starts when a cookie you name takes a value you name, or when your plugin calls &lt;code&gt;window.pressedHog.grantConsent()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;the important bit is that “opted out” is posthog’s own opted-out state, not a wrapper that delays loading the script. it means the library is present, the identify call is queued, and accepting the banner flips one switch.&lt;/p&gt;
&lt;h2&gt;woocommerce events that don’t lie&lt;/h2&gt;
&lt;p&gt;three events: product added to cart, checkout started, order completed. the last one carries totals and line items and is the one that goes wrong on most installs, because the thank-you page gets refreshed. so it’s deduplicated with a flag written to the order, and it only fires when the order key in the url matches the order, the same check woocommerce’s own thank-you page does. you can’t enumerate order ids and read totals out of the analytics.&lt;/p&gt;
&lt;h2&gt;the proxy, and what it refuses to do&lt;/h2&gt;
&lt;p&gt;ad blockers block posthog’s domains. the fix is to serve it from your own: a rewrite rule turns &lt;code&gt;yoursite.com/phog/…&lt;/code&gt; into a server-side relay to your posthog host, forwarding the visitor’s ip so geolocation still works.&lt;/p&gt;
&lt;p&gt;a proxy on a wordpress site is a thing that gets abused, so this one is narrow. it only relays to the host you configured. it only accepts a fixed list of first path segments, the ones posthog actually uses: &lt;code&gt;static&lt;/code&gt;, &lt;code&gt;e&lt;/code&gt;, &lt;code&gt;i&lt;/code&gt;, &lt;code&gt;decide&lt;/code&gt;, &lt;code&gt;capture&lt;/code&gt;, &lt;code&gt;batch&lt;/code&gt;, &lt;code&gt;array&lt;/code&gt;, &lt;code&gt;s&lt;/code&gt;, &lt;code&gt;flags&lt;/code&gt;. anything else is refused. the request body is capped, the upstream timeout is five seconds. it cannot be pointed at another host and it cannot be used to fetch things. for a very busy site you’d put a cdn in front instead, and the readme says so.&lt;/p&gt;
&lt;h2&gt;analytics where the client already is&lt;/h2&gt;
&lt;p&gt;most people who run a wordpress site will not log into posthog. so there’s a posthog page inside wp-admin: pageviews, unique visitors, change against the previous period, a traffic chart, top pages, referrers and devices over seven, thirty or ninety days. it’s queried server-side from posthog’s query api and cached for five minutes. the personal api key that makes this work is stored non-autoloaded so it never rides along in the object cache on public requests, and it never appears in page html.&lt;/p&gt;
&lt;h2&gt;the rest&lt;/h2&gt;
&lt;p&gt;feature flags evaluated server-side, with a shortcode and a php helper, so you can gate content in a template. qr codes with utm tags and a unique id per code, generated in the browser so the url never goes to a third party. role exclusions so admins and editors don’t pollute the numbers.&lt;/p&gt;
&lt;h2&gt;the security review&lt;/h2&gt;
&lt;p&gt;before i published it i ran an adversarial review: separate agents attacking each surface, each finding re-verified by a sceptical pass. ssrf through the proxy, header injection, xss, and injection into the analytics queries were all looked at specifically. the hardening in this note is what came out of that. i’d recommend the exercise for any plugin that makes outbound requests on behalf of a site.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>how this site is put together</title>
    <link href="https://chelseahopkins.co.uk/notes/how-this-site-is-put-together/"/>
    <id>https://chelseahopkins.co.uk/notes/how-this-site-is-put-together/</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <summary>a static page on github pages, self-hosted fonts, cookieless analytics, and a fifty-line build script for these notes.</summary>
    <content type="html">&lt;p&gt;this site used to be wordpress. one page, a theme, a page builder, a hosting bill, and a plugin update every time i looked at it. for a page that says who i am and how to reach me, that’s a lot of moving parts.&lt;/p&gt;
&lt;p&gt;now it’s three files.&lt;/p&gt;
&lt;h2&gt;the shape of it&lt;/h2&gt;
&lt;p&gt;one html file, one stylesheet, one script. the copy is written straight into the html, the same copy that’s on my &lt;a href=&quot;https://github.com/ookpassant&quot;&gt;github profile&lt;/a&gt;, and they live in the same repo so they can’t drift apart. github pages serves it. the domain points at github. there’s no server of mine anywhere in the chain.&lt;/p&gt;
&lt;p&gt;it’s laid out as a field guide because everything i make seems to have an animal in it. jackdaw, hedgehog, courser. i noticed the pattern late and leaned in.&lt;/p&gt;
&lt;h2&gt;fonts&lt;/h2&gt;
&lt;p&gt;fraunces for the display type, source serif for the body, jetbrains mono for the labels. they’re served from the site itself rather than from google, in latin subsets only, so the whole set is under a megabyte and the page makes no third-party requests at all.&lt;/p&gt;
&lt;h2&gt;analytics without a banner&lt;/h2&gt;
&lt;p&gt;posthog is on it, because posthog is on everything i make. it runs cookieless: memory persistence, no person profiles until someone identifies themselves, no session recording. nothing is written to the visitor’s browser, so there’s nothing to ask consent for. the trade-off is that returning visitors look like new ones. for a site like this, that’s fine.&lt;/p&gt;
&lt;p&gt;on top of pageviews there are three named events: someone clicked the hire button, someone clicked email, someone left for another site. that’s the whole funnel.&lt;/p&gt;
&lt;h2&gt;these notes&lt;/h2&gt;
&lt;p&gt;a note is a markdown file with a title and a summary at the top. push it and a small node script turns it into a page in the same design, adds it to the list on the homepage, the sitemap, the feed and the llms.txt. github actions runs the script. i never see the output.&lt;/p&gt;
&lt;p&gt;that’s it. no framework, no build tool beyond one script, nothing to update. if it’s still working in five years i’ll be pleased but not surprised.&lt;/p&gt;
</content>
  </entry>
</feed>
