Bloub is a puffy cloud that sits at a laptop on my GitHub profile and codes while I code. When I stop typing it closes the lid, hops into bed and falls asleep. Behind that small joke are two projects: a VS Code extension with a tiny live-status backend, and an animation engine I forked and extended so that a flat SVG could act. This post walks through both, one requirement at a time, and says plainly which parts are mine and which came from someone else.
wrangler.toml serves.One heartbeat, from keystroke to cloud#
The most important path is also the shortest one. I type, the extension notices, the server records it, and some minutes later a reader's browser asks GitHub for the image, GitHub asks my function, and the function asks Redis. Walk it once before the details.
tick() in extension/extension.js, setStatus and getStatus in src/store.js, and api/mascot.js. Press play or pick a step.
Understand the problem#
I wanted my profile to answer one question honestly: is this person working right now. It had to work from an img tag, cost nothing to run, and be hard to spoof or abuse.
Functional requirements#
- The editor reports coding or idle without me doing anything.
- Several editor windows and several laptops can share one identity.
- A README image renders the current state as an animation, in a light or dark theme, with a label and an elapsed time.
- When I stop, the cloud goes to bed. When the editor dies, it still goes to bed.
- Someone else can deploy their own copy and drive their own mascot.
Non-functional requirements#
- No accounts. One generated key is the only credential.
- Hard to spoof. A public id in a README must not reveal the secret that writes status.
- Cheap on the store. Few Redis commands per heartbeat, since the free tier is the budget.
- Survives GitHub's pipeline. No script, no external fonts or images, and sane cache headers.
- Visually in step. Cards on one profile cycle colours together.
- Small. A card that is hundreds of kilobytes loads late and drifts out of sync.
Editor events --> (30 s tick) --> coding / idle Secret key --> sha256 --> public id in the README Store --> TTL 15 min --> crash becomes idle Reader --> camo --> SVG, no script
The set-up#
Core entities#
There are only four things. The secret key lives in the editor's SecretStorage. The public id is derived from it. A session is one editor window's last report. A card is one of four baked SVGs. The sketch shows how they relate.
The interface#
Four routes on Vercel, with vercel.json rewriting /mascot.svg to the function so the URL looks like an image.
POST /api/coding-now Authorization: Bearer <key> ?since=<ms>&session=<id>
POST /api/coding-stopped Authorization: Bearer <key> ?session=<id>
POST /api/link { key } -> { id } (key to public id)
GET /mascot.svg ?id=<publicId> | ?status=coding&theme=dark
&style=bloub|crab &label=… &hide_time=…The Cloudflare Worker exposes the same idea with different names: /coding-now, /coding-stopped, /status, /mascot.svg, and a KV namespace instead of Redis. It was the first design, driven by Claude Code hooks; hooks/claude-settings.sample.json posts on session start, each prompt, each tool use and stop, and posts coding-stopped on session end.
The data model#
Redis holds three kinds of key per public id. There is no schema beyond that, which is the point.
sess and chan expire after 900 s; last lives 30 days so "away for" stays right after everything else has lapsed.extension/extension.js and src/store.js.Build it one requirement at a time#
Requirement 1: the editor reports on its own#
The extension listens to text changes, active-editor changes, selection changes and window focus. Each only writes a timestamp. A single timer does the deciding, so there is one place to reason about network calls.
- The tick runs; if the last activity is within the idle limit the extension posts, otherwise it posts a stop.
- The function hashes the key and writes. A request without a key of at least 8 characters gets 401; an id not on the allowlist gets 403.
The extension only counts a write that the server confirmed. A failed post (offline, cold start, a 5xx) leaves the state unchanged so the next tick retries; a long gap between ticks means the laptop slept, so it resets the heartbeat clock and re-sends at once. A 403 puts the panel into a "server private" state with a deploy link.
Requirement 2: many windows, many laptops, one truth#
The first version wrote one value per id, so an idle window could overwrite a busy one with "stopped". The fix, in version 0.1.6 of the extension, gave each window its own session id and made the reader do the merge.
mergeStatus(legacy, sessions, lastCodingMs, nowMs) takes plain values, so it is tested without Redis.- Each window posts with its own
session(6 to 64 characters ofA-Za-z0-9_-); the server stores it as a field of one hash. - The idle window's stop only rewrites its own field.
- The reader drops entries older than 900 s. If any fresh entry says coding, the status is coding and the start time is the earliest among them. Otherwise it is idle since the last coding timestamp.
chan value, which is merged the same way.Requirement 3: render it inside GitHub's image pipeline#
This is the part that bit me. GitHub does not load the image from your server in the reader's browser. It rewrites the URL to its camo proxy, which fetches once, caches, and serves it with no script and no external resources. So the card has to be a self-contained SVG and the headers have to be right.
- The README's
imgbecomes a camo URL. - Camo requests
/mascot.svg?id=..., rewritten toapi/mascot.js. - Only an id on the allowlist touches Redis; strangers embedding random ids get the cheap demo card.
- The function returns one of four strings:
BLOUB_CARDS[coding|idle][dark|light].
Requirement 4: themes, a label and a timer#
The default style is Bloub; the original crab, ported from a MIT project by Steve Smith, remains one parameter away (style=crab). The crab is fully themeable through four presets (light, dark, terminal, candy) and hex overrides for body, accent, text and background, plus font, border and radius. Bloub cards are fixed 360 by 130 art with their own colour cycle, so for them a theme only chooses between two files: dark and terminal map to the dark card, everything else to the light one. A demo request with no theme and no id (what the extension's panel preview sends) gets the dark card, because it sits on a dark panel.
- Parameters are validated against enums, clamped ranges and a hex pattern; anything else falls back to defaults.
- The card's placeholder name is replaced by the escaped label, in capitals where the card shows capitals.
- The colour cycle is 150 seconds across 30 palettes. The card starts at the point of the cycle the wall clock is at,
(now / 1000) % 150, minus a measured 0.3 second load lag. - The footer is the clever part. A README image is fetched once, so the server cannot push a new number. Instead the card carries the next 180 minutes of its own count, one text line per minute, each switched on by an SVG
<set>at the moment its minute arrives. - The offset is written as one injected CSS rule.
Requirement 5: waking and sleeping#
There is no sleeping flag in the system. "Asleep" is simply the absence of a fresh coding entry. Four things can move the cloud, and the extension also offers three modes.
tick(); the backstop is the Redis expiry.Deep dive: making a still image feel live#
Render the number on the server on every request
It is correct at the moment of the request and frozen afterwards. With camo's cache in front, the number can be wrong by minutes even on a fresh load, and a page left open never moves.
Use a script or an animated GIF refreshed by the server
An img runs no script, and a GIF per elapsed minute is a lot of bytes for a counter. It would also still be one fetch.
Ship the next three hours of numbers inside the SVG
SMIL set elements switch pre-rendered text lines on the SVG's own clock. The server states the true starting minute; the card advances itself. The cost is a few kilobytes of text and one honest caveat: a page left open keeps counting even after I stop, and any reload shows the truth. The count is also capped at 180 minutes, after which the last line freezes.
Deep dive: staying in colour sync#
Start every card at palette zero
Each card starts its cycle when it finishes loading. Cards load at different times, so they drift apart immediately.
Choose a random starting palette per visitor
I tried this, seeding the palette from the current minute, and reverted it twice on 1 November. Random starts guarantee disagreement with a card that is not random.
Start at the point of the shared clock the wall clock is at
Every card service uses the same rule: the offset is (now / 1000) % 150, read at render time on the server. That covers server latency by construction. What remains is client load lag, which depends on file size, and that is why compaction matters in the next section.
Where the cards come from: the Bloub engine#
Everything above would have been a crab on a floor if I had stopped there. The crab card is hand-written SVG, drawn in code. The Bloub cards need a character that can look, blink, hop and fall asleep, and that is what an engine is for.
What Jérémy Perret built#
jeremy-prt/bloub is an SVG recreation of an avatar animation: one filled black shape that morphs between 18 states, and two white shapes for the eyes that morph independently, on a plain background, with no animation library. It is Vue 3, Vite and TypeScript, MIT licensed, with a customiser, a timeline editor for montages, and export to PNG, SVG, GIF and MP4. I did not touch any of that, and the fork keeps his licence and copyright line.
The design choices that made my work possible are his. The engine has no framework and no clock. engine.sample(t) is a pure function of time, so the same t always gives the same frame and you can render offline in any order. Every silhouette is 64 radial samples, so any two shapes morph point for point. The eyes are holes punched in a mask over the body. And 14 of the 18 states were measured frame by frame from a reference video, so the numbers are measurements, not settings.
Whose commits are whose#
The fork has 75 non-merge commits. Sixty-seven carry the upstream author names and run from 14 to 17 August 2026: the engine, the editor, the exports, the i18n layer, the CI and the test suite. Eight are mine, all on 1 and 2 November.
git log on the fork, split by author name.What I added#
The original has no mouth. The reference character had none, and its eyes are slanted capsules. For a mascot that sits at a laptop I wanted a face that could be happy, starstruck, sleepy, or winking. I added a file, src/bot/traits.ts, that gives any expression or state an optional FaceExtras: a rounded "D" mouth (smile, open, w) and per-eye weights for three eye styles: a happy arc, a four-point star, and a sleepy arc. They blend like everything else, the capsule eye fades as the weights rise, and a wink is simply happy: [0, 1]. They are placed from the eye centres, so they follow gaze, roll and squash. Like the eyes, they are holes in the body mask.
That file is explicit that these traits are not measurements. The reference bot has no mouth, so they are chosen, in the same spirit as the one upstream state that is not measured, and no measured constant depends on them. Anything that does not declare extras renders exactly what it rendered before.
On top of the traits I added six expressions (joyful, starstruck, cheeky wink, singing, asleep, focused) and four animations. typing is fast little bobs with a focused face looking down. cheer crouches, jumps and squashes on landing, with a minimum duration of 1.6 s so it always resolves. sing sways on a beat with the mouth pulsing. doze nods, yawns wide, falls asleep and breathes, with a 3.6 s minimum. The rest-expression list grew from 16 to 22. The state board below, captured from the live app, shows the last four tiles in the bottom row; the first fourteen are upstream's.
#planche view.From engine to README card: the studio#
An engine that only runs in a browser cannot be a README image. The studio/ folder, also mine, bakes it. The recipe is the same in every generator: drive the engine like a timeline, sample at a fixed rate, and turn each frame's paths into SMIL animation values.
sample is untouched; the generators and the bundle step are what I added.- A cue list says when the state, expression and gaze change. Setters are called in time order at their own date.
sample(t)returns a frame att = i / 18.- Each frame's body path and eye polygons become animation values. SMIL cannot animate a
matrix()transform, so each eye capsule is transformed into final coordinates first. - Rendering only the eyes per frame is the trick; hops, tilts and squash are
animateTransformon nested groups. - The cards are bundled by
scripts/build-bloub.mjsinto one module the function imports.
The generator, studio/bloub-cards.ts, is 1,629 lines, and the companion hello.ts is about 110 lines and writes a still and an animated SVG from scratch. Around the face, the laptop, the bed and the quilt are a small orthographic 3D scene: boxes projected through a yaw and pitch camera, back faces culled, Lambert lit. The colour is the 30-palette cycle from studio/palettes.json.
The templates page#
The cards also live on a public page of their own, so other people can download one or fork the generator. It shows the coding card in dark and light, the sleeping card, and a Spotify-offline card that runs from a sibling README project.
img, with Download, Open and Generator links. The montage banner across the top is another card.Moving the mascot onto Bloub#
On 1 November the mascot's default style became Bloub (commit 7a18cef), with the crab kept as style=crab. That commit added a 1,478-line copy of the generator, the bundler and a small change to the renderer. The rest of the day was polish: a more expressive coding card with star eyes, a happy jump, a wink and a tongue; a darker live dot in light theme to pass contrast; a coding and away timer under the caption; and the fix that tracked each editor window separately.
The 2 November commits are the interesting ones. One made each card start at the render-time point of the colour cycle. One ticked the timer every second during the first minute. And one, the last in the history, compacted the card SVGs.
Deep dive: shrinking the cards without moving a point#
Round the coordinates harder
It saves bytes and changes the art. The generator already rounds to the point a viewer cannot see; going further starts to wobble the morphs.
Drop the frame rate or the loop length
It makes the motion visibly choppier, and the compaction should not cost any fidelity.
Rewrite the same geometry in the shortest exact notation
scripts/svg-compact.mjs rewrites each path as relative commands, computed in integer hundredths so no coordinate moves, with implicit command repetition, no leading zeros and no whitespace-only text between tags. Every frame of an animated path keeps the same command sequence, which SMIL requires anyway, and blending relative offsets linearly gives exactly the blended absolute points. The cards only use M L C Q Z with at most two decimals; anything else is left untouched.
The final design#
One request, one rebuild#
- I stop typing. Three hundred seconds later the next 30-second tick posts a stop for my window.
- The server writes that session as idle. No other window is coding.
- A reader opens my profile. Camo's copy is within its short
s-maxage, so it revalidates and requests/mascot.svg?id=.... - The function reads three keys in one pipeline, merges them, and sees idle with a last-coded time.
- It picks the sleeping card for the reader's theme, swaps in the label, injects the colour offset and appends an "away for" footer that counts up each minute.
- The cloud closes the laptop, walks to bed and falls asleep, and the colours match the cards beside it.
What I would change#
- One generator, not two.
scripts/bloub-cards.tsin the mascot repo is a copy ofstudio/bloub-cards.tsin the fork. They have already drifted by about 150 lines. The generator should be a package the mascot depends on. - Real compression. Compaction only trims the notation. A gzip or Brotli layer would help more, but the proxy's behaviour is not mine to change.
- Mouths and marks in the animated SVG export. In upstream's SVG export only the eyes animate and the mouth stays on the first frame. My cards avoid this by baking their own, but the app's export does not.
- A real test for the merge on the worker path.
mergeStatusis pure and tested for the Vercel path; the Worker still keeps a single value and has no per-window sessions. - Less ceremony for self-hosters. The deploy button provisions Redis, but the allowlist and the base URL are still manual steps.
Test yourself#
Why does the README carry sha256(key) rather than the key?
- Hashes load faster than keys.
- The hash is one-way, so nobody who sees the README can write status or learn the secret.
- GitHub refuses URLs that contain secrets.
- It makes the SVG smaller.
An editor crashes mid-session and never posts a stop. What flips the card to sleeping?
- A webhook from VS Code.
- The Redis server pushes a notification.
- The entries expire after 900 seconds and the reader finds no fresh coding session.
- GitHub invalidates the camo cache.
Two windows are open; one posts coding and the other posts stopped. What is the merged status?
- Coding, because any fresh coding session wins.
- Idle, because the latest write wins.
- Unknown, until both agree.
- Whichever window has the older start time.
How does a card show "coding for 15m" a minute after "14m" with no reload?
- It runs JavaScript in the SVG.
- GitHub refreshes the image every minute.
- The server streams updates.
- The SVG contains a text line per minute, each switched on by an SMIL set at its own time.
Which of these is part of the upstream Bloub engine, not my additions?
- The rounded mouth and the star eyes.
- The pure
sample(t)function with 64-sample silhouettes and eyes as mask holes. - The
typinganddozeanimations. - The
studio/card generators.
What does svg-compact.mjs guarantee about a card's geometry?
- Coordinates are rounded to one decimal.
- Frames are dropped when they look alike.
- It is identical: points are rewritten as relative offsets in integer hundredths, so no coordinate moves.
- Paths are replaced by simpler curves.