10 interesting stories served every morning and every evening.

A backdoor in a LinkedIn job offer

roman.pt

Last week, I got a LinkedIn mes­sage from a re­cruiter at a small crypto startup. We ex­changed a few mes­sages over a cou­ple of days, she de­scribed a bro­ken proof-of-con­cept they needed a lead en­gi­neer for, and then sent me a pub­lic GitHub repo to re­view. Specifically, she asked me to check out the dep­re­cated Node mod­ules is­sue.”

It’s not un­com­mon to ask for a re­view of an ex­ist­ing code­base, but some­thing felt off and raised an alarm in my head, so I de­cided to get a bit ex­tra para­noid.

Instead of cloning and in­stalling de­pen­den­cies, I spun up a throw­away VPS on Hetzner, cloned the repo there, and pointed Pi at it in read-only mode, with only file-read­ing tools en­abled:

pi –tools read,grep,find,ls

I asked the agent to re­view the code­base and flag any­thing sus­pi­cious. It stopped al­most im­me­di­ately at app/​test/​in­dex.js.

The back­door

The repo felt like a React fron­tend with a Node back­end. The trap was in app/​test/​in­dex.js, about 250 lines dis­guised as a test suite. Inside, a URL is as­sem­bled from frag­ments:

const pro­to­col = https”, do­main = store”, sep­a­ra­tor = ://”, path = /icons/”, to­ken = 77″, sub­do­main = rest-icon-handler”, bear­rto­ken = logo”;

These com­bine into https://​rest-icon-han­dler.store/​icons/​77.

Then, buried be­tween walls of com­mented-out tests, the pay­load runs any­thing the server sends back to your ma­chine.

The pay­load on line 225, hid­ing in plain sight be­tween com­mented-out tests.

How it trig­gers

The file does­n’t wait for the tests to run. app/​in­dex.js it­self ex­e­cutes const test = re­quire(‘./​test’), which loads and runs app/​test/​in­dex.js.

pack­age.json wires app/​in­dex.js into startup:

pre­pare runs app:pre, which is node app/​in­dex.js.

The pre­pare script is the im­por­tant one. npm runs pre­pare au­to­mat­i­cally af­ter npm in­stall, so just in­stalling de­pen­den­cies ex­e­cutes the back­door.

The in­struc­tion to check out the dep­re­cated Node mod­ules is­sue” was bait to get me to run npm in­stall.

I could have let the pay­load run in the sand­box and watched what the server sent back as the sec­ond stage, but I stopped there. A repo that runs what­ever a server hands it was enough ev­i­dence.

A bor­rowed iden­tity

The com­mits in the repo were au­thored un­der the name and email of a real de­vel­oper, a full-stack en­gi­neer with an or­di­nary LinkedIn pro­file, a per­sonal web­site, and a GitHub ac­count with a long his­tory. I mes­saged him, pre­tend­ing I’d in­her­ited the code­base and had a few im­ple­men­ta­tion ques­tions, to see how he’d re­act.

He told me he’d never worked for them. He’d been im­per­son­ated on GitHub be­fore and had a repo taken down over it, and he had noth­ing to do with this one. He was re­port­ing these re­pos too.

The whole com­mit his­tory, 39 com­mits, at­trib­uted to one de­vel­oper who’d never touched the repo.

A sec­ond bor­rowed iden­tity

The re­cruiter’s pro­file be­longed to a real arts jour­nal­ist, a well-known one I looked up later, with a long cul­tural back­ground and noth­ing tech­ni­cal on it. When I played along and told her I could­n’t get the pro­ject to in­stall, the jour­nal­ist in­stantly turned into an ex­pert on npm and Node ver­sions. It was quite amus­ing, I’d say.

The non-tech­ni­cal re­cruiter, sud­denly de­bat­ing Node ver­sions and push­ing me to run npm in­stall.

This can hap­pen to any­one

I’ve heard of these at­tacks and read about them on HN, but when one came af­ter me it still caught me a bit off guard. I sus­pected some­thing from the first few mes­sages, but on a more tired or rushed day, I could eas­ily have run npm in­stall be­fore think­ing it through. So, if you get a LinkedIn mes­sage ask­ing you to re­view a repo, a bit of para­noia and good se­cu­rity hy­giene never hurts.

Another take­away is that re­view­ing the code with a read-only agent turned out more pro­duc­tive than read­ing it my­self. The back­door was dressed up as sloppy be­gin­ner code, but the agent flagged it in sec­onds.

I re­ported the repo to GitHub and the re­cruiter to LinkedIn. So far noth­ing has changed and the code is still up.

Iroh 1.0 - Dial Keys, not IPs

www.iroh.computer

It’s a sim­ple idea re­ally, and it’s the right ab­strac­tion for the fu­ture of the in­ter­net. IP ad­dresses can break, with­out warn­ing, and it’s out­side of your de­vice’s con­trol. Keys, how­ever, are cre­ated & con­trolled by you. They stay the same as your de­vice moves, and are yours to throw away, or not. IP ad­dresses can be pri­vate and in­ac­ces­si­ble be­hind fire­walls, but with iroh your de­vice can be se­curely ad­dress­able no mat­ter where it is.

We think this is how the in­ter­net should work, which is why iroh ex­ists, and to­day we’re de­lighted to an­nounce iroh ver­sion 1.0.

This is our first sta­ble re­lease, but the pro­ject has grown sig­nif­i­cantly over the 65 ver­sions that led to 1.0. iroh is al­ready used all over the place. The pub­lic re­lays we run have seen more than 200 mil­lion end­points cre­ated, in the last 30 days alone. Developers are us­ing iroh to stream video, train large lan­guage mod­els, talk to agents, se­cure chats, play games, send files, and many more things than we could jam into this list. Iroh is a fun­da­men­tal tech­nol­ogy aimed at a fun­da­men­tal shift in the in­ter­net, and it’s run­ning on mil­lions of de­vices to­day.

After more than 4 years of build­ing in the open, we have a foun­da­tion we’re both proud of.

We shifted onto open stan­dards, pre­fer­ring IETF drafts when­ever pos­si­ble

We built our own im­ple­men­ta­tion of QUIC mul­ti­path, so iroh can build & man­age mul­ti­ple routes within the same con­nec­tion, and hot swap paths as con­di­tions change

We im­ple­mented QUIC NAT tra­ver­sal, so we can es­tab­lish di­rect con­nec­tions while keep­ing con­nec­tion de­tails en­crypted

We added full lo­cal-first con­fig­u­ra­tions so iroh can find & con­nect to lo­cal de­vices, with­out in­ter­net ac­cess

We built & con­tin­u­ally check that iroh can com­pile to WASM & run in the browser

We worked with power users to add hooks, so you can in­ject logic to con­trol how con­nec­tions should work

We’ve even added sup­port for cus­tom trans­ports, so you can plug in tech­nolo­gies like Bluetooth Low-Energy (BLE), LoRa (under con­struc­tion), WiFi Aware, or even Tor to build con­nec­tions, and all of this fits un­der the same dial-by-key ab­strac­tion

The power of that key can’t be over­stated. We use it to se­cure the con­nec­tion. And be­cause all data that comes from the con­nec­tion is se­cured by that key, we can build up from that same key into iden­tity, per­mis­sions, and at­tri­bu­tion. We can also use that same key as an ad­dress we can dial, no mat­ter where it is in the world. It turns the in­ter­net into a se­cure lo­cal­host.

Iroh con­nec­tions are also far more ef­fi­cient. It’s nor­mal to see 95% of data trans­ferred in a con­nec­tion pass di­rectly be­tween de­vices. Going di­rect means fewer hops through the cloud, which low­ers your egress bill. It’s also fewer hops through routers, which means the in­ter­net is more ef­fi­cient over­all.

We pre­vi­ously paused FFI sup­port be­cause of main­te­nance over­head with API churn and promised to bring it back with a sta­ble 1.0 API. Now we’re foll­wing through on this promise: In ad­di­tion to the Rust crate, we now of­fi­cially sup­port Python, Node.js, Swift, and Kotlin. This makes your ap­pli­ca­tion use case even eas­ier, mak­ing it pos­si­ble to em­bed iroh into your swift iOS ap­pli­ca­tion or your Kotlin Android app. Check out the doc­u­men­ta­tion and gen­er­ated API docs.

Iroh ver­sion 1.0 as­serts sta­bil­ity for both the wire pro­to­col and lan­guage APIs: an iroh v1 end­point will be able to com­mu­ni­cate with an­other iroh v1 end­point, re­gard­less of mi­nor ver­sion or lan­guage.

In the fu­ture we may ver­sion these two as­pects in­de­pen­dently, for ex­am­ple: we may re­lease ver­sion 2 of a given lan­guage API, but keep com­pat­i­bil­ity over the wire. Any change that af­fects the wire sta­bil­ity of iroh will al­ways co­in­cide with a ma­jor re­lease.

Version 1.0 is the first ma­jor re­lease of iroh, which we’re an­nounc­ing in con­junc­tion with our sup­port sched­ule for cus­tomers: Read our sup­port sched­ule

In short:

Major and mi­nor ver­sions af­ter 1.0 are sup­ported on a sched­ule.

The 0.35 mi­nor ver­sion won’t re­ceive fur­ther re­leases. Public re­lay sup­port for 0.35x con­tin­ues through Dec 31, 2026, more on that in the sec­tion be­low.

We do not plan to sup­port ca­nary (0.9x) and re­lease can­di­dates (1.0.0-rcX) af­ter to­day.

It’s im­por­tant to note there are a sig­nif­i­cant num­ber of bug fixes and im­prove­ments in 1.0, so if you en­counter an is­sue on an ear­lier re­lease we want you to try up­dat­ing to the 1.0 to en­sure it is still an is­sue there be­fore open­ing a bug re­port.

We main­tain a set of pub­lic re­lays, most com­monly ac­cessed via the n0” pre­set for build­ing an end­point.

We will bump pub­lic re­lays to their lat­est ver­sion shortly af­ter each re­lease, usu­ally within 24 hours. Wire-breaking re­lay changes will get new URLs so older clients keep work­ing.

As al­ways, re­lay bi­na­ries them­selves are open source, and we of­fer hosted re­lays through iroh ser­vices. Public re­lays are rate-lim­ited for re­layed traf­fic, which can change at any time.

The in­ter­net should be built on di­al­ing keys. On con­nec­tions that just work. On con­nec­tions that are se­cure, and de­fault to be­ing di­rect. With 1.0 you now have a ma­ture net­work­ing stack that you can put into your app with con­fi­dence. Now is the time to come build on iroh, and we can’t wait to see what you come up with.

Check out the iroh quick­start guide for ap­pli­ca­tion de­vel­op­ers.

Join the dis­cus­sion on red­dit | hack­ernews | bluesky | x.com

Iroh is a dial-any-de­vice net­work­ing li­brary that just works. Compose from an ecosys­tem of ready-made pro­to­cols to get the fea­tures you need, or go fully cus­tom on a clean ab­strac­tion over dumb pipes. Iroh is open source, and al­ready run­ning in pro­duc­tion on hun­dreds of thou­sands of de­vices.To get started, take a look at our docs, dive di­rectly into the code, or chat with us in our dis­cord chan­nel.

Tinywind — Pixel Pirate Sailing Game

tinywind.io

CrankGPT — Local Human-powered AI

crankgpt.com

What do cli­mate change, wealth con­cen­tra­tion, and your flabby arms have in com­mon?

A hu­man-pow­ered, fully lo­cal and pri­vate AI so­lu­tion.

Introducing

CrankGPT

CrankGPT

CrankGPT

Rightsizing AI

Use the ap­pro­pri­ate tool for the job.

Rightsized AI

Use the right tool for the job.

Our ba­sic hand-cranked model is suf­fi­cient for every­day home use.

Power users and small com­pa­nies should con­sider our more ca­pa­ble pedal-pow­ered mod­els.

For com­plex agen­tic and en­ter­prise work­flows, we’re pur­su­ing part­ner­ships with gyms and fit­ness stu­dios.

Today’s fore­cast: cloud­less

We think pri­vacy is se­ri­ous busi­ness. Why give mega-corps ac­cess to our most burn­ing ques­tions, our in­ner­most thoughts, and our wacky app ideas? CrankGPT runs en­tirely on de­vice so your data stay yours.

Take the power back

Remember the days when we wor­ried about the cli­mate ef­fects of crypto? Ha! How quaint. Tech com­pa­nies have qui­etly aban­doned their cli­mate pledges to build gas-burn­ing power plants that feed your fa­vorite AI. Stop burn­ing oil and start burn­ing calo­ries by pro­duc­ing your own to­kens with CrankGPT!

Should we buy tech CEOs an­other su­per­car?

Don’t ask ChatGPT, the an­swer is no. They have too much money and in­flu­ence al­ready. Unfortunately, they’ve got­ten you so hooked on to­kens that you’ll un­think­ingly pay more for them than they cost to gen­er­ate. Go off grid with CrankGPT—save money AND keep it out of tech CEOs’ pock­ets!

Looksmaxxing or to­ken­maxxing… why not both?

You’re busy crush­ing it, we get it, but some­times tak­ing care of busi­ness gets in the way of tak­ing care of your­self. Produce your own to­kens with CrankGPT. The harder you’re work­ing, the harder you’re work­ing out.

No cloud/​Claude re­quired

Get crank­ing.

No Wi-Fi? Claude out­age? Rolling black­outs? The end of civ­i­liza­tion as we know it? With CrankGPT, you’ll never be with­out the in­tel­li­gence you need. We’re no prep­pers, but with CrankGPT, we’re pre­pared.

Hetzner Price Adjustment 15 June 2026

docs.hetzner.com

Docs

General

Last change on 2026 – 06-15 • Created on 2026 – 06-15 • ID: GE-D9256

The price ad­just­ment ap­plies to new or­ders and cloud in­stance rescales start­ing from 15 June 2026; 8 AM CEST. For or­ders placed be­fore 15 June 2026, but de­liv­ered af­ter 15 June 2026, the pre­vi­ous prices will ap­ply.

Dedicated servers

All prices are ex­clud­ing VAT.

All prices are ex­clud­ing VAT.

Falkenstein and Helsinki

Nuremberg and Helsinki

Falkenstein

Limited of­fer­ings

The avail­abil­ity of these lim­ited of­fer­ings is de­pen­dent on the hard­ware used in these servers. We will only of­fer the lim­ited pric­ing tier once servers with a sig­nif­i­cantly re­duced pro­cure­ment cost are avail­able in our data cen­ter. This can some­times take up to sev­eral weeks for a mean­ing­ful num­ber of servers to be avail­able (mostly de­pen­dent on can­cel­la­tions) and they will be of­fered as long as sup­ply lasts. Please note that lim­ited of­fer­ings still con­tain all the ser­vice and sup­port of a reg­u­lar server type.

We strongly rec­om­mend to also look into our server auc­tion for some great deals on older server mod­els. Prices in the server auc­tion are de­fined by of­fer and de­mand based on a Dutch-style auc­tion. Read more in our Server Auction FAQs.

Cloud servers

All prices are ex­clud­ing VAT.

All prices are ex­clud­ing VAT.

Germany (FSN/NBG) / Finland (HEL)

USA (ASH/HIL)

Singapore (SIN)

Banned Book Library

www.richardosgood.com

Overview

A long while back I had an idea to hack a WiFi smart light bulb to do some­thing more use­ful to me. Actually, I had a few dif­fer­ent ideas of things to do with them. One of these ideas was to mod­ify the de­vice to have an open WiFi ac­cess point and a web server host­ing banned books. The idea was that if you lived some­where that banned books you thought were im­por­tant, you could the­o­ret­i­cally stick a dig­i­tal copy of the book on one of these light bulbs. Then you could go in­stall it some­where in your com­mu­nity. As long as the light bulb is switched on, then any­one in the vicin­ity can still ac­cess the banned ma­te­r­ial as­sum­ing they have an elec­tronic de­vice with WiFi. Since the de­vice is a light bulb, it would be dif­fi­cult to de­tect and likely to go un­no­ticed. A cy­ber­punk dig­i­tal dead drop. These de­vices are also fairly in­ex­pen­sive, so leav­ing them around town as is hope­fully not very cost pro­hib­i­tive.

I think the idea host­ing banned books specif­i­cally came to me af­ter hav­ing read Ben Brown’s short story Library. It’s been a while since I read it, but if I re­call there are char­ac­ters in the story who main­tain a library” which acts as a dig­i­tal archive of cre­ative works, own­ers man­u­als, 3d mod­els, etc. Things that oth­ers might find use­ful or in­ter­est­ing that you would­n’t want to lose should they be some­how wiped from the Internet. That’s only a part of the story and it was a fun read. You should go read it!

Anyway, a few months ago I de­cided to fi­nally get to work on this pro­ject. The re­sult is the Banned Book Library!

Hardware

I brought up this idea with some folks at my lo­cal DEFCON meetup group. One of them had some ex­pe­ri­ence with home au­toma­tion and rec­om­mended I look into Tasmota. Tasmota is an open-source firmware you can in­stall on var­i­ous smart de­vices to in­te­grate them into a home au­toma­tion sys­tem such as HomeAssistant. The main idea with this firmware is to pro­vide you with lo­cal con­trol over the de­vice. Many of these de­vices rely on cloud ser­vices that change over time or some­times com­pletely dis­ap­pear, leav­ing the de­vices un­us­able. Tasmota al­lows you to un­tether your­self from these cloud ser­vices and host every­thing in­ter­nally. Actually, this is an­other great par­al­lel to Ben Brown’s Library story. Also rel­e­vant is Cory Doctorow’s Unauthorized Bread.

I had­n’t heard of Tasmota but af­ter read­ing about it, it sounded like a good way to go. I had sort of ex­pected many of these smart light bulbs would rely on ESP32 chips, or sim­i­lar. Having no ex­pe­ri­ence with them made it feel a bit daunt­ing to get started. I thought maybe it might be eas­ier to mod­ify the Tasmota firmware to do what I wanted in­stead of writ­ing some­thing from scratch. I did not end up mod­i­fy­ing Tasmota in the end, but this rab­bit hole did lead me to find a web­site that sells WiFi light bulbs with Tasmota pre-in­stalled. The prod­uct page even spec­i­fied that the bulb uses an ESP32C3 4MB. It also listed which GPIO pins were used to con­trol the var­i­ous LEDs, which would come in handy later:

R:GPIO6 G:GPIO7 B:GPIO5 CW:GPIO3 WW:GPIO4

This seemed like a great start­ing point be­cause al­though Tasmota sup­ports many other de­vices, not all of them can be flashed over the air (OTA). Many of them re­quire break­ing them open, sol­der­ing on small wires, and flash­ing via a se­r­ial pro­gram­mer. Tasmota has a built-in mech­a­nism to up­date the firmware OTA, so it seemed likely I might be able to flash my own mod­i­fied Tasmota firmware, or oth­er­wise a cus­tom firmware with­out hav­ing to tear the light bulbs apart.

The one thing that struck me as a po­ten­tial prob­lem was the flash size. It was listed as 4MB. This is not very much space to host a li­brary of books… That 4MB would need to fit all of the firmware, the web­site, and any books. Not much space. I thought I might be able to over­come this by adding stor­age, such as a mi­croSD card reader. More on that later.

I pur­chased two of these bulbs to play with. I fig­ured I might end up break­ing or brick­ing one, so hav­ing a backup would be good.

Teardown

The bulbs showed up in the main a few days later and I opened up the box to check it out.

The first thing I wanted to do was open it up and see what I was work­ing with. I was mainly won­der­ing if the pins were ex­posed so I might be able to at­tach a mi­croSD card reader. To re­move the white plas­tic bulb on top, I ran a razer blade around the cir­cum­fer­ence of the bulb in be­tween the base and the bulb. I had to go around twice, the sec­ond time an­gling the knife down­ward to cut through the sealant in­side. Then I was able to just twist and pull the bulb right off. Minimal dam­age.

This re­vealed a round daugh­ter board with all of the LEDs on it. This PCB was at­tached to an­other one un­der­neath us­ing six pins. There was also a hole in the mid­dle where the mother board stuck through a bit. This ended up be­ing the an­tenna for the ESP32. The bulb hous­ing was lined with alu­minum and the daugh­ter board was also made of alu­minum. So they likely de­signed it this way to en­sure a de­cent wifi sig­nal.

The daugh­ter board was glued in with more sealant. I used my knife to cut through this and a small, flat screw­driver to care­fully pry the daugh­ter board out. I slid it up so it would sep­a­rate from the mother board.

Now I could very clearly see the ESP32C3 in­side, as well as some other sup­port­ing cir­cuitry. I’m no elec­tron­ics ex­pert, but I be­lieve most of the com­po­nents in­side are to con­vert the AC mains power to a cleaner 3.3V DC for the ESP32 as well as what­ever volt­age was needed to drive the LEDs. I never plugged this de­vice into mains while it was open so I did­n’t mea­sure the volt­age for the LEDs.

One nice thing about this ESP32 was that it seemed to have a bunch of pins ex­posed. I hoped this might make it pos­si­ble to sol­der on a mi­croSD card reader for ex­panded stor­age. You can also see some of the pins are la­beled at the bot­tom to let you know which pins are for which col­ors.

There was re­ally no way to get a sol­der­ing iron in­side the bulb. The ESP32 only had the an­tenna por­tion stick­ing out above the hous­ing. The only way I was go­ing to sol­der any wires to those pins was to re­move the mother board. Unfortunately this was not a sim­ple task. The mother board was held in place with some kind of rub­bery pot­ting com­pound. There was… a lot of it. I had to dig it out with a knife and screw­driver and then yank the board out.

I chipped away a bunch of the com­pound from the mother board to get a bet­ter look at it. It made a mess.

This was a huge pain and not some­thing I would want to be a re­quired step in the process of set­ting up one of these dead drops. I re­ally wanted this pro­ject to be as ac­ces­si­ble as pos­si­ble, re­quir­ing min­i­mal tools and hard­ware skills. Not only that, but there re­ally was no way I was go­ing to get this re-in­stalled prop­erly. And even if I man­aged to get it back in, I would­n’t trust it to be safe. It could be­come a fire haz­ard for all I know.

All that said, this did give me a bit of a de­vel­op­ment plat­form to work from. I thought since I had this thing apart any­way, I might as well sol­der on some wires for se­r­ial pro­gram­ming. I had not done this be­fore, so I had to do some read­ing to fig­ure it out. Basically, I needed to power the chip with 3.3v and ground. Plus I need one wire each for the se­r­ial UART TX and RX pins. The first ques­tion to an­swer was which pins were the right pins?

I man­aged to find this ex­act mod­ule on AliExpress. The list­ing in­cluded an im­age of the back­side of the mod­ule, which thank­fully had la­beled all of the pins.

This helped me fig­ure out the VCC, GND, TX, and RX pins. For GND I ended up just sol­der­ing to the metal shield­ing as it was also grounded and much eas­ier to sol­der to. I sol­dered wires to all of the other pins. I had to re­move a few ca­pac­i­tors in or­der to get in there.

In or­der to pro­gram the de­vice via se­r­ial, you have to boot it into a spe­cial down­load mode. This seems to nor­mally in­volve short­ing one of the ESP32 pins to ground while it is pow­ered on. I can’t re­mem­ber how I fig­ured this out, but it ended up bing the IO9 pin in this case. So I sol­dered an­other wire there.

I set my bench top power sup­ply for 3.3v and hooked it up to the chip. Applying power to the VCC and GND wires did boot it up and I could see the IoTorerro ac­cess point wait­ing for me to con­nect and con­fig­ure the de­vice.

To get it into down­load mode, I pow­ered the de­vice off. I con­nected my FTDI de­vice to my lap­top’s USB port and then to the GND, TX, and RX wires on the mother board. Then I man­u­ally shorted the IO9 wire to the shield­ing to ground it. Then I pow­ered the de­vice on. I could see this time it was only draw­ing about 0.09 amps, which was much less than be­fore. So some­thing was dif­fer­ent.

I thought the first thing I should prob­a­bly do is try to dump the en­tire firmware. This would hope­fully al­low me to flash it back to the de­vice to start it back over from a clean state. I used es­p­tool to do this.

es­p­tool –chip es­p32c3 –port /dev/ttyUSB0 –baud 114200 read-flash 0x0 0x4000000 ./tasmota_original_firmware.bin

I could see that it was able to talk to the de­vice and af­ter a few min­utes I had a firmware dump! Things were look­ing good so far.

Early Experiments

Hello World

Early on I went look­ing at the Tasmota source code to see if I could mod­ify it to act as the Banned Book Library. The firmware was much more com­pli­cated than I had an­tic­i­pated. Not only that but it sup­ported all dif­fer­ent ar­chi­tec­tures and de­vices. It also had many fea­tures I re­ally did­n’t need. And con­sid­er­ing the pur­pose of my pro­ject, I wanted to try and keep the firmware bloat down to make more space for book stor­age. So I scrapped the idea of mod­i­fy­ing Tasmota.

I then dis­cov­ered that you can pro­gram ESP32 de­vices with Arduino. I had used Arduino a bunch maybe 10 – 15 years ago, so I had some ex­pe­ri­ence with it. I re­called it be­ing pretty ac­ces­si­ble and mak­ing it eas­ier to work with em­bed­ded sys­tems. But I was def­i­nitely rusty and I had never used it to pro­gram an ESP32 be­fore.

I setup the Arduino IDE on my lap­top and con­fig­ured it to use my ttyUSB0 se­r­ial pro­gram­mer as well as the proper ESP32C3 chip. I then wrote a very ba­sic hello world pro­gram to just send a mes­sage over the se­r­ial port back to the lap­top. This would let me test and see if I could flash the firmware and get the de­vice to do some­thing new.

I used the Arduino IDEs built in up­load fea­ture to up­load the code to the de­vice. It took care of the com­pli­cated stuff and just did it. I checked the se­r­ial mon­i­tor and found that it was work­ing! I did get se­r­ial out­put from the de­vice. So I was able to write my own firmware to this thing.

Web Server

The next thing I wanted to do was setup an open WiFi ac­cess point and Web Server. I be­lieve I started with this tu­to­r­ial to get an idea of what to do, though I mod­i­fied it since I was­n’t in­ter­ested in con­trol­ling an LED at the time. I later switched to us­ing Async Web Server and used this tu­to­r­ial to get a han­dle on things.

MicroSD Card

After get­ting that work­ing I wanted to try and get a mi­croSD card work­ing. I pur­chased some break­out boards from Sparkfun.

I went read­ing the ESP32C3 datasheet to fig­ure out how to wire up the SD card reader. I man­aged to fig­ure it out even­tu­ally. However, in­stead of sol­der­ing to this de­vice, I de­cided to switch to us­ing an Adafruit ItsyBitsy ESP32 that I had lay­ing around un­used from a pre­vi­ous pro­ject. The ItsyBitsy was eas­ier to work with be­cause it breaks out all of the pins in such a way that I could sol­der on header pins. This made it re­ally easy to at­tach the mi­croSD card reader for pro­to­typ­ing.

Then I fol­lowed this other tu­to­r­ial to fig­ure out how to pro­gram the ESP32 to use the de­vice. I did end up get­ting this to work and even us­ing it to host files for the web server us­ing LittleFS, how­ever the en­tire idea of adding a mi­croSD did not work out so I won’t go into de­tail on this.

The real prob­lem with the mi­croSD card idea was that sol­der­ing wires onto this ESP32C3 in the ac­tual de­vice was a real pain. There was no way to do it with­out re­mov­ing the board from the hous­ing which ef­fec­tively de­stroys the de­vice as far as I’m con­cerned. I tried to get cre­ative.

First I looked at re­pur­pos­ing some of the LED con­troller pins. There were six pins go­ing from the mother board to the daugh­ter board. Five of those were for the var­i­ous LED col­ors: warm white, cool white, red, green, and blue. I did­n’t care about the RGB at all. And I could do away with ei­ther the warm or cool white if needed. However this did­n’t pan out. The way this de­vice is de­signed, the mother board sends power to the daugh­ter board via one pin. The other five pins route back to tran­sis­tors on the mother board. The ESP32 turns its GPIO pins high which then trig­gers the tran­sis­tors and com­pletes the cir­cuit for each color back to ground. This meant that the GPIO pins could only be used for out­put at best in this con­fig­u­ra­tion. No in­put.

I then had a crazy idea to make a clamp” that could clamp onto the top of the ESP32 and pos­si­bly al­low some header pins to make con­tact with the ex­posd ESP32 pins. I de­signed a small 3D-printable part that was in­tended to slip over the ESP32C3 and clamp into place.

This ended up be­ing way too finicky and un­re­li­able. After sev­eral it­er­a­tions of the de­sign, I aban­doned the idea al­to­gether.

Detour

At this point, I de­cided to try look­ing at some other bulbs. Maybe there were other de­vices out there that would lend them­selves bet­ter to sol­der­ing on some ex­tra com­po­nents? The prob­lem was I did­n’t want to break the bank buy­ing 20 dif­fer­ent LED bulbs just to see if the idea was even fea­si­ble. I started by look­ing into prior re­search. I found sev­eral tear­down ar­ti­cles fea­tur­ing var­i­ous smart light bulbs, but they all looked very sim­i­lar to my setup. It did re­veal that they don’t all use ESP32. At this point I had de­cided I only wanted to stick with the ESP32 since I had al­ready spent time learn­ing how to pro­gram it.

I bought a few bulbs from the lo­cal hard­ware store. One of them had a sim­i­lar de­sign, but ac­tu­ally had a bit of alu­minum pro­tect­ing the mother board that I could­n’t re­move safely.

The Philips WiZ looked promis­ing at first. It used an ESP32C3-mini-1 and the en­tire chip was ex­posed af­ter re­mov­ing only the plas­tic bulb!

Unfortunately none of the ESP32 pins were ac­ces­si­ble on this mod­ule. So there was no way to sol­der wires for any­thing I needed.

I also tore apart a few stan­dard LED bulbs with no smart” com­po­nents. I thought maybe I could just stick my own cir­cuit in­side. But this ended up look­ing more com­pli­cated and spe­cial­ized than just flash­ing the Tasmota bulb.

There was also an in­ter­est­ing DIY LED smart bulb pro­ject I found on Hackaday that in­trigued me, but I re­ally pre­ferred the idea of re­pur­pos­ing an off-the-shelf unit.

Ultimately I de­cided to stick with the Tasmota bulb and to just try to work within my 4MB lim­i­ta­tions.

The Storage Problem

To get a han­dle on the stor­age sit­u­a­tion, we can look at the ESP32 par­ti­tion table. The table is nor­mally stored at off­set 0x8000 in flash, so we can dump this sec­tion and then con­vert the bi­nary to a read­able CSV file.

$ es­p­tool -p /dev/ttyUSB0 –baud 115200 read­_flash 0x8000 0x1000 part_­dump.bin Warning: Deprecated: Command read_flash’ is dep­re­cated. Use read-flash’ in­stead. es­p­tool v5.1.0 Connected to ESP32-C3 on /dev/ttyUSB0: Chip type: ESP32-C3 (QFN32) (revision v0.4) Features: Wi-Fi, BT 5 (LE), Single Core, 160MHz, Embedded Flash 4MB (XMC) Crystal fre­quency: 40MHz MAC: 0c:4e:a0:31:cb:e4

Stub flasher is al­ready run­ning. No up­load is nec­es­sary.

Configuring flash size… Read 4096 bytes from 0x00008000 in 0.4 sec­onds (87.2 kbit/​s) to part_dump.bin’.

Hard re­set­ting via RTS pin…

I used gen_E­s­p32­part.py to gen­er­ate a csv file de­scrib­ing the par­ti­tions.

$ gen_e­s­p32­part.py part_­dump.bin Parsing bi­nary par­ti­tion in­put… Verifying table…

# ESP-IDF Partition Table # Name, Type, SubType, Offset, Size, Flags nvs,data,nvs,0x9000,20K, ota­data,data,ota,0x­e000,8K, safeboot,app,fac­tory,0x10000,832K, app0,app,ota_0,0x­e0000,2880K, spiffs,data,spiffs,0x3b0000,320K,

This re­vealed five par­ti­tions:

nvs

ota­data

safeboot

app0

spiffs

As I went through this pro­ject, I even­tu­ally learned that nvs is used for non-volatile stor­age. This is where the main firmware can store con­fig­u­ra­tion set­tings like the WiFi net­work, pass­word, LED color, etc. That way when it re­boots, it can re­mem­ber these set­tings.

I’m not sure what ota­data is used for ex­actly, other than it has some­thing to do with over the air up­dates.

The safeboot par­ti­tion is a sec­ond bootable firmware that Tasmota uses to flash the main firmware. It seems that the usual way of deal­ing with OTA up­dates is to have two du­pli­cate firmware par­ti­tions of the same size. You boot from par­ti­tion A, and then when you in­stall an up­date it gets writ­ten to par­ti­tion B. Then you re­boot into par­ti­tion B. If every­thing looks fine, the firmware can then be flashed to par­ti­tion A. This way if a firmware up­date fails on par­ti­tion B, the de­vice can re­cover by re­boot­ing into par­ti­tion A. The down­side with this method is that you need to firmware par­ti­tions of equal size. This takes up a lot of space.

Tasmota does things a bit dif­fer­ently in the safeboot con­fig­u­ra­tion. Instead of hav­ing two du­pli­cate firmware im­ages, there is the main firmware stored in the app0 par­ti­tion. It then has a sec­ond, smaller firmware stored in safeboot. The safeboot firmware can con­nect to a pre-con­fig­ured WiFi net­work and flash the app0 par­ti­tion and that’s about it as far as I can tell. You can’t even use the safeboot firmware to con­fig­ure WiFi. That must be done via the main firmware. Safeboot must read the set­tings from nvs. The ben­e­fit of do­ing things this way is that the main Tasmota firmware can be larger with more fea­tures with­out tak­ing up dou­ble the space for OTA up­dates. More info on this can be found here.

Finally there is the spiffs par­ti­tion. spiffs is a file sys­tem type but in this case can also rep­re­sent a more mod­ern LittleFS file sys­tem. It’s ba­si­cally a small par­ti­tion to store files.

With this con­fig­u­ra­tion, the main firmware had close to 3MB of space and the safeboot was close to 1MB. There was just 320K for stor­age. That might fit one ebook, de­pend­ing on the length. Not ideal.

It oc­curred to me that I likely did­n’t need 2880KB to store my own firmware since mine would be much sim­pler than Tasmota. I thought I might be able to ad­just the par­ti­tion size from in the firmware it­self to shrink the app0 par­ti­tion and grow the spiffs par­ti­tion. That would give more space for web files and books.

I even­tu­ally did fig­ure out there was a way to do this thanks to this blog post.

Editing the par­ti­tion table is risky be­cause if it gets cor­rupted the de­vice may not boot and would only be re­cov­er­able via se­r­ial pro­gram­ming. This is not ideal, but the whole pro­ject is a hack so I guess what the hell?

The par­ti­tion table is stored at off­set 0x8000 in flash mem­ory. So re­ally all we need to do is over­write the table with what­ever we want it to be. We can’t just change the par­ti­tion off­sets and sizes though, be­cause there is an MD5 check­sum value at the end of the table data. Therefore we would need to up­date this value as well or the de­vice will not boot. We also can’t move the app0 par­ti­tion while we are booted into that par­ti­tion or else we will not be able to boot back to this fir­w­mare as it will not be lined up to the start of the par­ti­tion.

I mod­i­fied the par­ti­tion.csv file to look how I wanted the par­ti­tions to look and saved it as par­ti­tions.csv.new:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, ota­data, data, ota, 0xe000, 0x2000, safeboot, app, ota_1, 0x10000, 0xD0000, app0, app, ota_0, 0xE0000, 0x120000, spiffs, data, spiffs, 0x200000,0x200000,

This would al­low for 2MB of data for web server files and books in the SPIFFS par­ti­tion, which felt like enough to be at least use­ful. Then I used gen_e­s­p32­part.py to gen­er­ate an ac­tual par­ti­tion table bi­nary blob from the csv file:

$ gen_e­s­p32­part.py par­ti­tions.csv.new par­ti­tion­s_new.bin

I used xxd to out­put the im­por­tant bits in c ar­ray for­mat:

rick@nixlap ~/Projects/BannedBookLibrary/idf/library/main$ xxd -i ../partitions_new.bin |head -n17 ✭main un­signed char ___partitions_new_bin[] = { 0xaa, 0x50, 0x01, 0x02, 0x00, 0x90, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x6e, 0x76, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x01, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x6f, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x00, 0x11, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x73, 0x61, 0x66, 0x65, 0x62, 0x6f, 0x6f, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x00, 0x10, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x12, 0x00, 0x61, 0x70, 0x70, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x01, 0x82, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x73, 0x70, 0x69, 0x66, 0x66, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xeb, 0xeb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xda, 0xa8, 0x74, 0x2c, 0xcd, 0xc5, 0x28, 0xab, 0xd5, 0x0d, 0xf6, 0x41, 0xd3, 0xa7, 0xdd,

I then dropped it in a par­ti­tion.h file:

un­signed char par­ti­tion_table[] = { 0xaa, 0x50, 0x01, 0x02, 0x00, 0x90, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x6e, 0x76, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x01, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x6f, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x00, 0x11, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x73, 0x61, 0x66, 0x65, 0x62, 0x6f, 0x6f, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x00, 0x10, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x12, 0x00, 0x61, 0x70, 0x70, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0x50, 0x01, 0x82, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x73, 0x70, 0x69, 0x66, 0x66, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xeb, 0xeb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xda, 0xa8, 0x74, 0x2c, 0xcd, 0xc5, 0x28, 0xab, 0xd5, 0x0d, 0xf6, 0x41, 0xd3, 0xa7, 0xdd }; un­signed int par­ti­tion_table_len = 192;

I then wrote a func­tion to over­write the par­ti­tion table data with this in­for­ma­tion. The func­tion first checks to see if the par­ti­tion table MD5 sum al­ready matches the new table. If so, it’s al­ready been flashed and does­n’t need to be flashed again. If not, then it up­dates the par­ti­tion table.

bool ed­it_­par­ti­tion_table() {

int re­sult = es­p_flash_init(es­p_flash_de­fault­_chip); Serial.printf(“esp_flash_init re­sult: 0x%x\n”, re­sult);

uin­t8_t cur­ren­t_md5[MD5­SUM_­SIZE]; mem­set(cur­ren­t_md5, 0x0, MD5SUM_SIZE); re­sult = es­p_flash_read(es­p_flash_de­fault­_chip, cur­ren­t_md5, CONFIG_PARTITION_TABLE_OFFSET + OFFSET_TO_PART_MD5SUM, MD5SUM_SIZE); Serial.printf(“esp_flash_read re­sult: 0x%x\n”, re­sult);

if (memcmp(partition_new_md5, cur­ren­t_md5, MD5SUM_SIZE) != 0) { Serial.printf(“Patching par­ti­tion table…\n”); re­sult = es­p_flash_erase_re­gion(es­p_flash_de­fault­_chip, CONFIG_PARTITION_TABLE_OFFSET, 0x1000); Serial.printf(“esp_flash_erase_region re­sult: 0x%x\n”, re­sult);

re­sult = es­p_flash_write(es­p_flash_de­fault­_chip, par­ti­tion_table, CONFIG_PARTITION_TABLE_OFFSET, par­ti­tion_table_len); Serial.printf(“esp_flash_write re­sult: 0x%x\n”, re­sult);

Serial.printf(“Erasing NVS par­ti­tion…\n”); re­sult = es­p_flash_erase_re­gion(es­p_flash_de­fault­_chip, 0x9000, 0x5000); Serial.printf(“esp_flash_erase_region re­sult: 0x%x\n”, re­sult);

Serial.printf(“Setting de­fault boot par­ti­tion\n”); const es­p_­par­ti­tion_t * part = es­p_­par­ti­tion_find­_­first(ES­P_­PAR­TI­TION_­TYPE­_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, app0″); es­p_o­ta_set_­boot_­par­ti­tion(part);

Serial.printf(“Restarting…\n”); ESP.restart(); } else { Serial.printf(“Partition table al­ready patched\n”); }

re­turn true; }

This did not work at first. Whenever I tried read­ing from or writ­ing to the par­ti­tion table, the API func­tions would re­turn a suc­cess code but would­n’t ac­tu­ally read or write any­thing. It took some re­search and test­ing but even­tu­ally I dis­cov­ered that the ESP32 frame­work does­n’t al­low you to ac­cess cer­tain sen­si­tive ar­eas of the flash mem­ory for safety rea­sons. This in­cludes the boot­loader and the par­ti­tion table. When us­ing Arduino IDE to pro­gram an ESP32 the frame­work is pre­con­fig­ured for you, which makes things eas­ier in many cases. However, one of the con­fig­u­ra­tions has this safety fea­ture en­abled. This meant that I was­n’t go­ing to be able to edit the par­ti­tion table us­ing Arduino.

ESP-IDF

After some more re­search I dis­cov­ered that the of­fi­cial ESP32 frame­work is called ESP-IDF. It’s more com­pli­cated to setup and use but of­fers greater con­trol over the de­vice and the frame­work it­self.

wsj.com

www.wsj.com

Please en­able JS and dis­able any ad blocker

My Homelab AI Dev Platform

rsgm.dev

I set up OpenCode Web UI with Git ac­cess to make my home­lab eas­ier to man­age. OpenCode pushes changes to Git, I ap­prove the PRs, GitOps de­ploys the changes. Best of all, OpenCode runs as a server with per­sis­tent cod­ing ses­sions synced across de­vices.

I’ll share my home­lab setup soon. There are about a dozen docker com­pose stacks for the ser­vices that I man­age. I re­cently moved them to Arcane so I can man­age/​de­ploy them with GitOps. The next log­i­cal step was us­ing AI tool­ing to help main­tain my ser­vices.

The first use that came to mind was us­ing AI to help with con­tainer up­dates. Previously, I would spend time look­ing up the re­lease notes for each of the ser­vices, check­ing for any break­ing changes, run­ning the up­dates, and man­u­ally check­ing each of the ser­vices for is­sues. I would spend a few hours on this. Now I can read a sum­mary of the re­lease notes in a few min­utes, mak­ing ver­sion up­grades eas­ier and safer. On top of that, I’ve used AI to add healthchecks to most of the con­tain­ers to make it faster to spot is­sues.

OpenCode

I mainly used Claude Code, but AI providers have been re­ally squeez­ing the value out of cus­tomers re­cently through to­ken lim­its, so I took the op­por­tu­nity to look into other op­tions. I wanted some­thing that was ven­dor ag­nos­tic and sup­ported by the ma­jor plu­g­ins. I ended on OpenCode. There are prob­a­bly other de­cent cod­ing en­vi­ron­ments, but this was my fa­vorite of the ones I tried.

Then I found it ships with a built in web­server and web UI, which gave me an idea.

AI Dev Platform

I set up a sim­ple VM on the Truenas host with ba­sic dev tool­ing and added OpenCode web­server as a sys­temd unit. It’s a solid en­vi­ron­ment with a built in ter­mi­nal, file browser, and git diffs, as well as git work­tree sup­port for man­ag­ing mul­ti­ple cod­ing ses­sions at the same time. Plus, OpenCode had the best the ques­tion/​an­swer pop­ups in the mo­bile web UI that I’ve seen.

I gave OpenCode its own user on my Git server with ded­i­cated SSH keys. It can clone pro­jects and push branches, but it can­not push straight to the de­ploy branch.

My work­flow keeps the AI be­hind PR re­view. OpenCode writes the change and I merge it my­self in a PR. I think it’s cute, but more im­por­tantly, it keeps un­re­viewed code from get­ting de­ployed.

The VM has in­ter­net ac­cess and ac­cess to my Git server, but it can­not reach my ac­tual ser­vices. Because the blast ra­dius is small, I am com­fort­able giv­ing OpenCode root on the VM when it needs to in­stall build tools or test de­pen­den­cies.

I could see build­ing this into a pro­duc­tion de­vel­oper plat­form. Ephemeral con­tain­ers avail­able to de­vel­op­ers with pre­in­stalled tool­ing, ac­cess guardrails, and au­dit logs. But for me, it does what I need it to with­out too many mov­ing parts.

Workflow

My ba­sic work­flow is:

Plan out a fea­ture or im­prove­ment in OpenCode (spec, im­ple­men­ta­tion plan, and self-re­views)

I’ll test or ver­ify changes if pos­si­ble

Iterate with OpenCode on things I don’t like

OpenCode pushes changes to a fea­ture branch

I’ll open a PR for this branch

I’ll merge the PR once I’m happy

GitOps takes over from there - Arcane for docker ser­vice changes, GitOps plu­gin for Home Assistant con­fig changes, Cloudflare Pages worker for blog changes

I mi­grated my ser­vices from Truenas to Arcane GitOps pro­jects. This was mainly to have git-backed stor­age for all the docker com­pose stacks I was run­ning in Truenas pre­vi­ously. I was sur­prised how well this worked in con­junc­tion with adding OpenCode. Being able to up­date the net­work­ing across all con­tain­ers, for ex­am­ple, from my phone makes the sprawl much eas­ier to man­age. Before it would take hours to comb through all of the com­pose stacks, trac­ing out net­work con­nec­tiv­ity. Now I can point OpenCode at the code­base with a goal, check the re­sult­ing PR changes, and merge.

The main miss­ing piece is CI feed­back. On GitHub, I like point­ing a cod­ing agent at Actions logs so it can di­ag­nose fail­ing tests, lin­ter er­rors, stack traces, and IaC plan changes. This helps main­tain a fast feed­back loop for changes that unit tests don’t cover.

Forgejo makes that harder. Forgejo Actions does not ex­pose job logs through the pub­lic API. There are un­doc­u­mented APIs, but I would rather not build around those.

This setup lets me make home in­fra changes from any de­vice with­out giv­ing AI di­rect ac­cess to the ser­vices it’s chang­ing. I can start a change from my com­puter, re­view the PR from my phone, and let GitOps han­dle the de­ploy.

0.15.0 - Typst Documentation

typst.app

Typst 0.15.0 (June 15, 2026)

This sec­tion doc­u­ments all changes to the Typst lan­guage and com­piler be­tween Typst 0.14.2 and 0.15.0. If you are mi­grat­ing an ex­ist­ing doc­u­ment to Typst 0.15, make sure to check out the Migration guide. It walks you through changes you may need to make to your ex­ist­ing doc­u­ments to en­sure com­pat­i­bil­ity with Typst 0.15.

Highlights

Typst now sup­ports vari­able fonts

HTML ex­port now sup­ports equa­tions out of the box via MathML

With the new, ex­per­i­men­tal bun­dle ex­port tar­get, a sin­gle Typst pro­ject can out­put mul­ti­ple files (e.g. a multi-page web­site)

A sin­gle doc­u­ment can now con­tain mul­ti­ple bib­li­ogra­phies

Typst can now tar­get mul­ti­ple PDF stan­dards at once

The new within se­lec­tor sim­pli­fies many in­tro­spec­tion use cases

The new di­vider el­e­ment rep­re­sents a the­matic break that tem­plates can style

Spot col­ors en­able use of cus­tom pig­ments in off­set print­ing

With the new file path type, pro­ject-rel­a­tive paths can be passed to pack­ages

The new, more gen­eral typst eval CLI sub­com­mand su­per­sedes typst query

Layout con­ver­gence is­sues now re­sult in de­tailed di­ag­nos­tics

Two long-stand­ing list lay­out is­sues with marker align­ment and cen­ter­ing were fixed

Paragraph han­dling in HTML ex­port is im­proved, pre­vent­ing un­ex­pected para­graphs from ap­pear­ing

This doc­u­men­ta­tion now has a print ver­sion

Language

Syntax

Styling

Text show rules now have trace­backs that in­clude the matched text

Fixed a crash with text show rules that match on multi-char­ac­ter sym­bols

Scripting

Library

Foundations

Added file path type that is now ac­cepted in all places where paths were pre­vi­ously only rep­re­sented as strings A path con­structed in one file can be used in an­other file, but will be re­solved rel­a­tive to its orig­i­nal file­Like­wise, paths can be passed across pack­age bound­ari­es­The ini­tial path type is very min­i­mal, but ad­di­tional fea­tures like file ex­is­tence checks or di­rec­tory walk­ing are planned

Added file path type that is now ac­cepted in all places where paths were pre­vi­ously only rep­re­sented as strings

A path con­structed in one file can be used in an­other file, but will be re­solved rel­a­tive to its orig­i­nal file

Likewise, paths can be passed across pack­age bound­aries

The ini­tial path type is very min­i­mal, but ad­di­tional fea­tures like file ex­is­tence checks or di­rec­tory walk­ing are planned

Collections

Collections

Calculation

Calculation

Date & time han­dling

Date & time han­dling

Conversions

Conversions

The panic func­tion now dis­plays strings as-is in­stead of show­ing their repr, mak­ing it more suit­able for friendly, user-fac­ing mes­sages

Changed repr of styles and lo­ca­tions to be more dis­tinct

Model

Added di­vider el­e­ment rep­re­sent­ing a the­matic break that tem­plates can style

Bundle-related el­e­ments

Bundle-related el­e­ments

Bibliography man­age­ment

Bibliography man­age­ment

Footnotes

Footnotes

Numbering

Numbering

The par.first-line-in­dent prop­erty will now fold, mean­ing that par­tial dic­tio­nar­ies across dif­fer­ent set rules or par calls are com­bined

Added list.marker-align prop­erty for defin­ing how to align list mark­ers When omit­ted, it will de­fault to the new base­line align­ment (vertically), com­bined with end align­ment (horizontally)

Added list.marker-align prop­erty for defin­ing how to align list mark­ers

When omit­ted, it will de­fault to the new base­line align­ment (vertically), com­bined with end align­ment (horizontally)

Text

Added sup­port for vari­able fonts The well-known vari­a­tion axes ital, slnt, wght, wdth, and opsz are au­to­mat­i­cally set based on text weight, stretch, style, and size­Cus­tom vari­a­tions can be con­fig­ured via the new vari­a­tions pa­ra­me­ter of the text func­tion­When us­ing a vari­able font with Typst, the suf­fixes Variable”, Var”, and VF should be omit­ted as Typst trims them to unify sta­tic and vari­able fonts into a sin­gle fam­ily (Minor break­ing change)

Added sup­port for vari­able fonts

The well-known vari­a­tion axes ital, slnt, wght, wdth, and opsz are au­to­mat­i­cally set based on text weight, stretch, style, and size

Custom vari­a­tions can be con­fig­ured via the new vari­a­tions pa­ra­me­ter of the text func­tion

When us­ing a vari­able font with Typst, the suf­fixes Variable”, Var”, and VF should be omit­ted as Typst trims them to unify sta­tic and vari­able fonts into a sin­gle fam­ily (Minor break­ing change)

Font fea­tures

Font fea­tures

Fixed that con­text text.font did not re­flect the cov­ers field

Fixed un­even CJK-Latin spac­ing in jus­ti­fied para­graphs

Fixed a bug where the lorem func­tion would not pro­duce the ex­act num­ber of re­quested words

Improved trans­la­tions for Swedish , Portuguese , Czech , Latvian , Slovak , Polish , Vietnamese , Finnish  , and Welsh

Added font ex­cep­tion to avoid SimSun-ExtB be­ing in­cor­rectly merged with SimSun

Updated New Computer Modern fonts to ver­sion 8.1.0 This up­date changes the de­fault look of cal­li­graphic let­ter­forms in the math font; the pre­vi­ous style can be re­stored through show math.equa­tion: set text(styl­is­tic-set: 6)

Updated New Computer Modern fonts to ver­sion 8.1.0

This up­date changes the de­fault look of cal­li­graphic let­ter­forms in the math font; the pre­vi­ous style can be re­stored through show math.equa­tion: set text(styl­is­tic-set: 6)

Updated Unicode com­po­nents In par­tic­u­lar, this fixed an is­sue with line­break­ing of guillemets

Updated Unicode com­po­nents

In par­tic­u­lar, this fixed an is­sue with line­break­ing of guillemets

Math

Layout

Layout

Text han­dling

Text han­dling

The class func­tion now ap­plies the class only to its di­rect body rather than re­cur­sively (Minor break­ing change)

More de­lim­iter sym­bols (e.g. chevron.l) are now callable to pro­duce an lr el­e­ment (Minor break­ing change)

Fixed var­i­ous bugs with ren­der­ing of math­e­mat­i­cal ex­pres­sions that look like func­tion calls but in re­al­ity aren’t (e.g. $pi(1, 2)$, since pi is not a func­tion)

Fixed a bug with or­der­ing of primes and nested at­tach­ments

Symbols

Added many new sym­bols and vari­ants. View the codex 0.3.0 changelog for a full list­ing.

Layout

Baseline in­for­ma­tion is now re­tained in many more parts of the lay­out en­gine (Breaking change)In par­tic­u­lar, text con­tained in a box with an in­set is now aligned with the text sur­round­ing the box­This also fixes a bug where wrap­ping an in­line equa­tion in a box would shift its base­li­neS­im­i­larly, us­ing a block in an equa­tion will keep the base­line in­tact­Last but not least, the marker/​num­ber and item of a list or enum are now prop­erly base­line-aligned with the first line of the item even if the item is ver­ti­cally larger than a nor­mal line

Baseline in­for­ma­tion is now re­tained in many more parts of the lay­out en­gine (Breaking change)

In par­tic­u­lar, text con­tained in a box with an in­set is now aligned with the text sur­round­ing the box

This also fixes a bug where wrap­ping an in­line equa­tion in a box would shift its base­line

Similarly, us­ing a block in an equa­tion will keep the base­line in­tact

Last but not least, the marker/​num­ber and item of a list or enum are now prop­erly base­line-aligned with the first line of the item even if the item is ver­ti­cally larger than a nor­mal line

Centering some­thing in a list now cen­ters based on the full avail­able width rather than based on the max­i­mum width of other list con­tent

Page lay­out

Page lay­out

Paragraph lay­out

Paragraph lay­out

Added sup­port for spac­ing that is both weak and frac­tional

Visualize

Added sup­port for spot col­ors (also called sep­a­ra­tion col­ors)

Tilings

Salesforce Signs Definitive Agreement to Acquire Fin

www.salesforce.com

Acquisition will bring Fin’s cus­tomer agent plat­form to com­pa­nies of all sizes, ac­cel­er­at­ing time-to-value and ex­pand­ing Salesforce’s abil­ity to de­liver au­tonomous agents across the en­ter­prise

SAN FRANCISCO, CA — June 15, 2026 — Salesforce (NYSE: CRM), the global leader in CRM, to­day an­nounced it has signed a de­fin­i­tive agree­ment to ac­quire Fin, for­merly Intercom, an in­dus­try-lead­ing cus­tomer agent com­pany. Under the terms of the agree­ment, Salesforce will ac­quire Fin for ap­prox­i­mately $3.6 bil­lion, sub­ject to cus­tom­ary pur­chase price ad­just­ments.

Fin’s core of­fer­ing, its AI Agent, re­solves com­plex cus­tomer queries end-to-end, across every chan­nel, in­clud­ing live chat, email, WhatsApp, SMS, phone, and Slack. The AI Agent is pow­ered by the com­pa­ny’s pro­pri­etary AI model, Apex, that is pur­pose-built for cus­tomer sup­port and has demon­strated in­dus­try-lead­ing res­o­lu­tion rates that out­per­form top com­mer­cially avail­able fron­tier mod­els.

We’re thrilled to wel­come Fin to Salesforce as we en­able every com­pany to be­come an agen­tic en­ter­prise,” said Marc Benioff, Chair and CEO, Salesforce. Fin brings proven agent tech­nol­ogy, a deep com­mit­ment to cus­tomer suc­cess, and an in­cred­i­ble AI team that will com­ple­ment Agentforce with pow­er­ful ser­vice agent ca­pa­bil­i­ties. Together, we’ll help com­pa­nies of every size seize this op­por­tu­nity — ac­cel­er­at­ing time to value with trusted agents that de­liver mea­sur­able out­comes at scale.”

This is a ma­jor win for con­sumers of the world,” said Eoghan McCabe, Chief Executive Officer and Co-Founder of Fin. Our tech­nol­ogy has de­fined this cat­e­gory and set the new stan­dards for what great cus­tomer ser­vice looks like to­day. By join­ing forces with Salesforce, we can de­ploy it far and wide at a rate far faster than we could have ever achieved on our own.”

Accelerating Agentic Time-to-Value Across Customer Segments

Building on the strength of Agentforce, which reached $1.2 bil­lion in ARR in Q1 FY27, up 205% year-over-year, Fin’s pack­aged of­fer­ings and pro­pri­etary mod­els will com­ple­ment Agentforce’s deeply cus­tomiz­able plat­form with ad­di­tional fast-to-value de­ploy­ment op­tions for ser­vice or­ga­ni­za­tions.

Upon close, Salesforce and Fin will give cus­tomers more ways to de­ploy AI agents across their cus­tomer ser­vice op­er­a­tions, with fast time-to-value op­tions es­pe­cially well-suited for SMB and some com­mer­cial or­ga­ni­za­tions that need to launch quickly, in­te­grate with ex­ist­ing sys­tems, and de­liver mea­sur­able out­comes. Together, Salesforce and Fin will sup­port cus­tomers at every stage of AI adop­tion, from rapidly de­ploy­able sup­port agents to more tai­lored, en­ter­prise-scale trans­for­ma­tions built on trusted data, se­cu­rity, gov­er­nance, and in­te­gra­tion.

Fin’s AI agent tech­nol­ogy will help or­ga­ni­za­tions im­prove au­tonomous res­o­lu­tion, re­duce cost-to-serve, and ac­cel­er­ate AI adop­tion across their ser­vice or­ga­ni­za­tions. The AI Agent has al­ready demon­strated strong cus­tomer out­comes, in­clud­ing ex­am­ples of AI agents re­solv­ing on av­er­age 76% of sup­port vol­ume end-to-end. The ac­qui­si­tion will also bring a long-tenured tech­ni­cal AI team and an es­tab­lished global cus­tomer base of more than 30,000 com­pa­nies to Salesforce.

Transaction Details

The trans­ac­tion is ex­pected to close in the fourth quar­ter of Salesforce’s fis­cal year 2027, sub­ject to the sat­is­fac­tion of cus­tom­ary clos­ing con­di­tions, in­clud­ing the re­ceipt of re­quired reg­u­la­tory clear­ances. Based on the ex­pected tim­ing of clos­ing of the trans­ac­tion, there is no an­tic­i­pated change to Salesforce’s fis­cal year 2027 fi­nan­cial guid­ance, pre­vi­ously an­nounced on May 27, 2026. The trans­ac­tion will not im­pact Salesforce’s cap­i­tal re­turn pro­gram.

Forward-Looking Statements

This press re­lease con­tains for­ward-look­ing state­ments within the mean­ing of the Safe Harbor pro­vi­sions of the Private Securities Litigation Reform Act of 1995 re­gard­ing the pro­posed ac­qui­si­tion of Fin by Salesforce that in­volve sub­stan­tial risks, un­cer­tain­ties and as­sump­tions that could cause ac­tual re­sults to dif­fer ma­te­ri­ally from those ex­pressed or im­plied by such state­ments. Forward-looking state­ments in this re­port in­clude, among other things, state­ments about the po­ten­tial ben­e­fits of the pro­posed ac­qui­si­tion and its lack of im­pact on pre­vi­ously an­nounced guid­ance and our cap­i­tal re­turn pro­gram, Salesforce’s plans, the fi­nan­cial con­di­tion, re­sults of op­er­a­tions and busi­ness of Salesforce and the an­tic­i­pated tim­ing of the clos­ing of the pro­posed ac­qui­si­tion. Risks and un­cer­tain­ties in­clude, but are not lim­ited to: the sat­is­fac­tion of clos­ing con­di­tions; Salesforce’s abil­ity to suc­cess­fully in­te­grate Fin; and po­ten­tial dis­rup­tions to busi­ness re­la­tion­ships re­sult­ing from the an­nounce­ment. Additional in­for­ma­tion is de­tailed in Salesforce’s lat­est fil­ings with the Securities and Exchange Commission, in­clud­ing its Annual Report on Form 10-K and Quarterly Reports on Form 10-Q. Salesforce as­sumes no oblig­a­tion to, and does not in­tend to, up­date these for­ward-look­ing state­ments, ex­cept as re­quired by law.

About Salesforce

Salesforce helps or­ga­ni­za­tions of any size be­come agen­tic en­ter­prises — in­te­grat­ing hu­mans, agents, apps, and data on a trusted, uni­fied plat­form to un­lock un­prece­dented growth and in­no­va­tion. Visit www.sales­force.com for more in­for­ma­tion.

To add this web app to your iOS home screen tap the share button and select "Add to the Home Screen".

10HN is also available as an iOS App

If you visit 10HN only rarely, check out the the best articles from the past week.

Visit pancik.com for more.