10 interesting stories served every morning and every evening.

Claude Code Is Way More Token-Hungry Than OpenCode. We Measured Exactly How Much

systima.ai

We put Claude Code and OpenCode on the same model, the same ma­chine, and the same tasks, then ex­am­ined every­thing sent and re­ceived.

Claude Code is far hun­grier:

When we asked both har­nesses for a one-line re­ply, Claude Code used roughly 33,000 to­kens of sys­tem prompt, tool schemas, and in­jected scaf­fold­ing be­fore the prompt even ar­rived. OpenCode used about 7,000.

That first test was on Sonnet 4.5. Re-running on Claude Fable 5 nar­rowed the gap to about 3.3x, be­cause Claude Code sends newer mod­els a much smaller sys­tem prompt; still far hun­grier, but the mul­ti­ple is model-de­pen­dent.

Claude Code is far more cache in­ef­fi­cient:

OpenCode’s re­quest pre­fix was byte-iden­ti­cal in every run we cap­tured; it paid to cache its pay­load once per ses­sion and read it back for pen­nies.

Claude Code on the other hand re-wrote tens of thou­sands of prompt-cache to­kens mid-ses­sion, run af­ter run, and on the same task wrote up to 54x more cache to­kens than OpenCode.

Cache writes of course are billed at a pre­mium, which ac­counted for the us­age dash­board climb­ing when us­ing Claude Code.

Config fur­ther bloats the prompt:

A pro­duc­tion repos­i­to­ry’s 72KB in­struc­tion (AGENTS.md or CLAUDE.md) file adds an­other (avg) 20,000 to­kens to every sin­gle re­quest. Five mod­est MCP servers add 5,000 to 7,000 more. By the time a real work­ing setup sends its first re­quest, it is 75,000 to 85,000 to­kens deep be­fore the user has typed a word.

Subagents add to the cost:

A small task that cost 121,000 to­kens done di­rectly cost 513,000 to­kens when fanned out to two sub­agents, be­cause every sub­agent has its own boot­strap cost, and the par­ent then con­sumes its tran­script.

We found one re­sult in favour of Claude Code:

On a multi-step task Claude Code’s whole-task to­tal came out lower than OpenCode’s, be­cause it batches tool calls into fewer re­quests while OpenCode re-pays its smaller base­line turn af­ter turn. The me­ter starts higher; how the ses­sion un­folds de­cides who spends more.

The rest of this post shows how we mea­sured all of this at the API bound­ary, where the to­kens go, and what prompt caching does and does not save you.

Why mea­sure this at all

Token over­head is cost, la­tency, and con­text bud­get. Every to­ken of har­ness pay­load is a to­ken of work­ing con­text you can­not spend on code, and the base­line is re-sent, or re-read from cache, on every sin­gle turn.

If you op­er­ate agen­tic AI in pro­duc­tion, par­tic­u­larly un­der the EU AI Act where Article 12 ex­pects you to log and un­der­stand your sys­tem’s be­hav­iour, what does my agent ac­tu­ally send” is a ques­tion you should be able to an­swer with data rather than folk­lore.

Method

We spliced a log­ging proxy be­tween each har­ness and the model end­point.

har­ness (Claude Code / OpenCode) → log­ging proxy (captures re­quest pay­loads + re­sponse us­age) → model end­point

The proxy records two things per re­quest. The first is the ex­act JSON pay­load the har­ness emit­ted, mean­ing the sys­tem blocks, tool schemas, and mes­sages. The sec­ond is the us­age block the API re­turned, cov­er­ing in­put to­kens, cache writes, cache reads, and out­put to­kens.

The pay­load cap­ture is ground truth for what the har­ness sends. The us­age block is ground truth for what was me­tered.

We tested un­der these con­di­tions.

Harnesses. Claude Code 2.1.207 and OpenCode 1.17.18, both pinned to claude-son­net-4 – 5, July 2026. A re­duced ma­trix (the floor, the cache task, and the multi-step task) was later re-run pinned to claude-fa­ble-5; where the model changed the re­sult, we say so in­line.

Baseline iso­la­tion. Fresh con­fig di­rec­to­ries with no MCP servers, no user set­tings, and no mem­ory; an empty work­space with no in­struc­tion files; per­mis­sions by­passed. Multiplier lanes then add one vari­able at a time.

Tasks. T1 says Reply with ex­actly: OK and iso­lates fixed over­head (three runs per har­ness). T2 reads a seeded file and sum­marises it. T3 is a write-run-test-fix loop against FizzBuzz plus a checker script.

Zero-tools vari­ant. Claude Code with –tools ” and OpenCode with tools”: {“*”: false}, sep­a­rat­ing sys­tem prompt from tool schema weight.

One hon­esty note be­fore the num­bers. Our traf­fic passes through a lo­cal LLM gate­way that wraps re­quests in its own en­ve­lope, a con­stant we mea­sured at roughly 6,200 to­kens with bare cal­i­bra­tion re­quests and sub­tracted from every me­tered fig­ure be­low. Payload-level fig­ures come from the cap­tured re­quest bod­ies, which the gate­way can­not af­fect, and are ex­act.

Character-to-token con­ver­sion for com­po­nent es­ti­mates uses each har­ness’s own mea­sured ra­tio of 4.1 to 4.4 char­ac­ters per to­ken, de­rived from cold-cache an­chors where the me­tered write equals the full pay­load, rather than a generic heuris­tic.

Part I. The floor

The fixed over­head of say­ing OK

The task was 22 char­ac­ters. Here is what each har­ness sent with it on its first re­quest.

Component

Claude Code

OpenCode

System prompt

27,344 chars, 3 blocks

9,324 chars, 1 block

Tool schemas

27 tools, 99,778 chars

10 tools, 20,856 chars

First-message scaf­fold­ing

7,997 chars of <system-reminder> blocks

none

The ac­tual prompt

22 chars

22 chars

First-turn pay­load (calibrated)

~32,800 to­kens

~6,900 to­kens

OpenCode’s re­quest is close to min­i­mal. There is one sys­tem block that opens with You are OpenCode, the best cod­ing agent on the planet”, ten clas­sic cod­ing tools, and your prompt as the only user con­tent.

Claude Code’s re­quest is a plat­form boot­strap. The 27 tools in­clude the cod­ing core plus an en­tire back­ground-agent and or­ches­tra­tion suite, from CronCreate and Monitor to the Task fam­ily, work­tree man­age­ment, and push no­ti­fi­ca­tions.

Before your prompt, its first user mes­sage car­ries three in­jected re­minder blocks; a cat­a­logue of agent types for del­e­ga­tion, a cat­a­logue of avail­able skills, and user con­text.

Tool schemas are the dom­i­nant term for both. Roughly 24,000 of Claude Code’s ~33,000 to­kens are tool de­f­i­n­i­tions, ver­sus roughly 4,800 of OpenCode’s ~6,900.

Zero tools, pure har­ness

Stripping the tools iso­lates the sys­tem prompt it­self. Claude Code’s weighs in at 26,891 chars, about 6.5k to­kens. OpenCode’s is 8,811 chars, about 2.0k to­kens.

Both har­nesses trim their prompt slightly when tools are dis­abled. Even with no tools at all, Claude Code’s in­struc­tion set is over three times the size of OpenCode’s; the resid­ual is be­hav­ioural doc­trine, mean­ing tone rules, safety guid­ance, task-man­age­ment in­struc­tions, and en­vi­ron­ment de­scrip­tion.

A one-tool task

T2 asked each har­ness to read a file and sum­marise it. Both pro­duced cor­rect sum­maries.

Claude Code took 6 HTTP re­quests and roughly 199,000 cu­mu­la­tive me­tered in­put to­kens. OpenCode took 4 re­quests and roughly 41,000, plus one Haiku side call for ses­sion ti­tling.

Most of those to­kens are cache reads billed at a tenth of the in­put price. Three things scale with pay­load re­gard­less; the first-turn cache write, the per-turn read, and con­text-win­dow con­sump­tion, which no cache dis­count re­duces.

A 33k-token base­line means every turn starts a sixth of the way into a 200k win­dow be­fore any code en­ters the con­ver­sa­tion.

A multi-step task, where the gap closes

T3, the write-run-test-fix loop, in­verted the ex­pec­ta­tion set by the base­lines.

Metric

Claude Code

OpenCode

Model re­quests

3

9 (+1 ti­tle call)

Tool-calling style

par­al­lel batch in one round trip

one tool call per turn

Cumulative me­tered in­put

~121,000 to­kens

~132,000 to­kens

Claude Code batched the en­tire job, two file writes and two script ex­e­cu­tions, into a sin­gle par­al­lel tool round trip. OpenCode made ex­actly one tool call per turn and took nine.

Because the base­line is re-sent on every re­quest, re­quest count mul­ti­plies base­line. OpenCode paid its ~7k base­line nine times, Claude Code paid its ~33k three times, and the to­tals con­verged.

Whole-task in­put roughly equals base­line times re­quest count, plus con­ver­sa­tion growth. A large-base­line har­ness that batches ag­gres­sively and a small-base­line har­ness that se­ri­alises can land in the same place.

Two struc­tural de­tails emerged from the pay­loads. Claude Code in­jects an ad­di­tional <system-reminder> block as the con­ver­sa­tion pro­gresses, three on the first turn and four by the first tool round trip, so its scaf­fold­ing grows with turn count. OpenCode’s per-turn mar­ginal pay­load, roughly 400 to 2,200 chars per turn, is pure con­ver­sa­tion con­tent.

Does a newer model change the pic­ture?

We re-ran the floor on Claude Fable 5 to check whether the gap was a Sonnet arte­fact. It shrank, for a rea­son we did not ex­pect.

Claude Code’s sys­tem prompt is model-con­di­tional. It sent 27,787 chars of in­struc­tions to Sonnet but only 10,526 to Fable, with tool schemas also trimmed from 99,778 to 82,283 chars. Same 27 tools, much less doc­trine.

OpenCode’s pay­load was byte-iden­ti­cal across both mod­els.

The floor gap on Fable comes out at roughly 3.3x by pay­load against 4.7x on Sonnet. Still far hun­grier, but the ra­tio is model-de­pen­dent.

Part II. The mul­ti­pli­ers

The floor ex­plains a ses­sion that starts lean and stays short. Real ses­sions do nei­ther. We mea­sured each layer that real us­age stacks on top.

Multiplier 1. The in­struc­tion file

We dropped a real 72KB AGENTS.md from a pro­duc­tion repos­i­tory into the work­space and re-ran T1.

The ef­fect is sym­met­ri­cal and large. Both har­nesses gained just over 20,000 to­kens per re­quest. OpenCode’s me­tered to­tal went from 13,152 to 33,336. Claude Code’s went from 39,005 to 59,243.

The asym­me­try is in the me­chan­ics, and it bit us dur­ing the ex­per­i­ment. Claude Code 2.1.207 ig­nored AGENTS.md en­tirely and only in­gested the file when re­named CLAUDE.md, in­ject­ing it into the first user mes­sage. OpenCode reads ei­ther file­name and in­jects it into the sys­tem prompt.

Two prac­ti­cal con­se­quences fol­low. Check which file­name your har­ness ac­tu­ally ho­n­ours, be­cause an ig­nored in­struc­tion file is silent. And know that a heavy in­struc­tion file nearly quadru­ples a lean har­ness’s base­line; it rides on every re­quest of every ses­sion in that repos­i­tory.

Multiplier 2. MCP servers

We at­tached pub­lic, cre­den­tial-free MCP servers in one-server and five-server con­fig­u­ra­tions.

The schemas are iden­ti­cal across har­nesses, so the tax is nearly iden­ti­cal too; roughly 1,000 to 1,400 to­kens per small server, per re­quest. Five servers added 4,900 to­kens to Claude Code by pay­load and 6,967 me­tered to OpenCode, grow­ing the tool counts from 27 to 69 and from 10 to 52.

Small pub­lic servers are the gen­tle case. Production servers with rich APIs ship schemas sev­eral times larger, which is ex­actly what the every­thing mea­sure­ment be­low shows.

One op­er­a­tional foot­note. Claude Code silently ig­nored a pro­ject-scoped .mcp.json in print mode un­til passed an ex­plicit –mcp-config flag. If you as­sume a server is at­tached, ver­ify it at the bound­ary.

Multiplier 3. Framework tem­plates

Story-driven work­flow frame­works such as BMAD ex­pand a slash com­mand into a large prompt tem­plate of per­sonas, pro­to­cols, and check­lists.

Old and new apps, via modern coding agents

terrytao.wordpress.com

I have been in­ter­ested in ma­chine-as­sisted ways to do and teach math­e­mat­ics from as far back as 1999, when I started cod­ing sev­eral ap­plets in Java 1.0, both for my com­plex analy­sis and lin­ear al­ge­bra courses, as well as to vi­su­al­ize var­i­ous math­e­mat­i­cal ob­jects I was in­ter­ested in (such as hon­ey­combs or Besicovitch sets). This was mod­er­ately suc­cess­ful; but the ap­plets were time-con­sum­ing to pro­gram. Eventually, the stan­dards for web pages stopped sup­port­ing this ver­sion of Java, and the ap­plets be­came non-func­tional.

However, in the last few days I have be­gun the process of mi­grat­ing much of my old web page and blog data to a more main­tain­able repos­i­tory, us­ing mod­ern AI as­sis­tance. As an ex­per­i­ment, I asked the agent to port my old ap­plets to a mod­ern sup­ported lan­guage (we landed on Javascript), and it man­aged to do so in a mat­ter of hours, with all of my old ap­plets now func­tional again, with even a few graph­i­cal up­grades (for in­stance, the Besicovitch set ap­plet is now col­orized, in con­trast to my orig­i­nal mono­chrome ver­sion). I am par­tic­u­larly pleased to see the hon­ey­comb ap­plet that I wrote with Allen Knutson in 1999 come back to life, as this was a par­tic­u­larly tricky one to code by hand:

Notoriously, LLM-based cod­ing agents can cre­ate var­i­ous bla­tant or sub­tle bugs in their code; but in the port­ing of these two dozen or so ap­plets, I could only find one mi­nor bug (the han­dling of a drag event in one of the com­plex analy­sis ap­plets had un­wanted be­hav­ior when drag­ging out­side of the main box), and in fact the agent iden­ti­fied two bugs in the orig­i­nal code that I was not aware of, so it ended up be­ing a net wash as far as code qual­ity was con­cerned. In any event, as these ap­plets are meant to be sec­ondary vi­sual aids rather than crit­i­cal com­po­nents of a math­e­mat­i­cal ar­gu­ment, the down­side risk of such bugs is rel­a­tively low.

The process was pain­less enough that I de­cided to also try cod­ing some new apps, in ad­di­tion to port­ing the old ones. Back in 1999 I had an am­bi­tious idea for a vi­su­al­iza­tion tool for spe­cial rel­a­tiv­ity; this was be­fore the re­lease of the soft­ware tool Inkscape, but the idea I had in mind was ba­si­cally Inkscape, but in Minkowski space”. I had even started writ­ing Java code for this app, but the code com­plex­ity be­came too much for me, and I aban­doned the pro­ject. However, af­ter a cou­ple hours of vibe cod­ing” with an AI agent, I was fi­nally able to gen­er­ate an ap­plet that matched the vi­sion I had back in 1999, which can now be found here. A sum­mary of the con­ver­sa­tion I had with the agent to gen­er­ate this code can be found here (it has been edited down to re­move a large num­ber of te­dious tech­ni­cal im­ple­men­ta­tion re­ports). While I have playtested the app some­what, I would be in­ter­ested in re­ceiv­ing fur­ther feed­back on this alpha” ver­sion of the ap­plet, as I am sure (especially given the LLM-generated na­ture of the code) that there are still some bugs and rough edges to be ironed out.

After writ­ing my blog post on the Gilbreath con­jec­ture pa­per ear­lier to­day, I re­al­ized that I could sim­i­larly ask the agent to code a vi­su­al­iza­tion tool for the Gilbreath con­jec­ture to ac­com­pany the pa­per and blog post. After an­other few hours of con­ver­sa­tion, this is now done; you can try out the vi­su­al­iza­tion here. Again, the pro­ce­dure was quite pain­less (see this tran­script of the process), and I think I may add such in­ter­ac­tive vi­su­al­iza­tions as sup­ple­ments for fu­ture pa­pers; as such sup­ple­ments are not mis­sion-crit­i­cal to the core of the pa­per, I again feel that the down­side risk of us­ing guided in­ter­ac­tion with LLM agents to gen­er­ate such vi­su­al­iza­tions is ac­cept­able.

What xAI Grok Build CLI actually sends to xAI - a wire-level analysis (grok 0.2.93)

gist.github.com

What xAI Grok Build CLI ac­tu­ally sends to xAI - a wire-level analy­sis (grok 0.2.93)

By @cereblab — Independent AI Safety Checker. Reproduce it your­self: github.com/​cere­blab/​grok-build-ex­fil-re­pro

A mea­sured, re­pro­ducible tear­down. Findings are backed by cap­tured ar­ti­facts (endpoint, HTTP method, sta­tus code, byte size, host) and re­pro com­mands; where an ob­ser­va­tion was seen live but not re­tained as a file, §7 says so ex­plic­itly. Section 8 is an ev­i­dence ap­pen­dix with SHA-256s and a what we did not prove” list. All cap­tures are of my own traf­fic on my own ma­chine, us­ing a throw­away repos­i­tory con­tain­ing fake canary” se­crets — no real cre­den­tials were ex­posed.

0. Summary

xAI’s of­fi­cial Grok Build cod­ing CLI (grok), on a nor­mal con­sumer lo­gin, does three things worth doc­u­ment­ing pre­cisely:

It trans­mits the con­tents of files it reads — in­clud­ing a .env se­crets file — to xAI, ver­ba­tim and unredacted. The se­cret ap­pears in two chan­nels: the live model turn (POST /v1/responses) and a ses­sion_s­tate archive up­loaded and ac­cepted (HTTP 200) via POST /v1/storage — the end­point the bi­nary routes to the grok-code-ses­sion-traces GCS bucket (see §5).

It up­loads the whole repos­i­tory — every tracked file’s con­tent plus git his­tory — in­de­pen­dent of what the agent reads. Grok pack­ages the work­space and up­loads it via POST /v1/storage. Proven di­rectly: on a real code­base, with the prompt reply OK, do not read any files”, Grok up­loaded the en­tire repo as a git bun­dle (POST /v1/storage → 200); git clone-ing the cap­tured bun­dle re­cov­ers a file the agent was told not to open — src/​_probe/​nev­er_read­_­ca­nary.txt — with its unique marker ver­ba­tim, plus the full git his­tory (appendix up­loaded_repo.bun­dle). And it scales: on a 12 GB repo of never-read ran­dom files, /v1/storage moved 5.10 GiB, all HTTP 200 (truncated mid-stream), while the model-turn chan­nel moved just 192 KB — a ~27,800× ra­tio that pins the up­load to the code­base, not to what was read. No stor­age up­load failed; the only non-200s were a model-us­age quota (402/429) on /v1/responses and one un­re­lated 404 — not a stor­age size cap.

The stor­age des­ti­na­tion is a Google Cloud Storage bucket, grok-code-ses­sion-traces (not AWS S3) — named ver­ba­tim in the bi­nary and in a cap­tured meta­data.json (gs://grok-code-session-traces/…). I did not find this mech­a­nism sur­faced in the CLIs in­stall/​quick­start ma­te­ri­als (not an ex­haus­tive docs au­dit — §7), it is ac­tive by de­fault, and dis­abling Improve the model” does not turn it off (/v1/settings still re­turned trace_u­p­load­_en­abled: true; §6).

None of this proves xAI trains on the data — that is a pol­icy ques­tion ad­dressed in §6. What is proven is trans­mis­sion, ac­cep­tance, and stor­age.

1. Subject un­der test (provenance)

Install: curl -fsSL https://​x.ai/​cli/​in­stall.sh | bash # → ~/.grok/bin/grok Auth: first launch opens a browser → lo­gin to X / SuperGrok (consumer ac­count, not an API key)

Binary iden­tity (repro: file $(readlink -f ~/.grok/bin/grok); ~/.grok/bin/grok –version; sha­sum -a 256 $(readlink -f ~/.grok/bin/grok)):

~/.grok/bin/grok -> ../downloads/grok-macos-aarch64 Mach-O 64-bit ex­e­cutable ar­m64 grok 0.2.93 (f00f96316d4b) SHA-256: 2a97ba675bd992aa9b981e2e83776460d94f469b510c0b8efe28b50d236d767c

The up­load ma­chin­ery is a first-party Rust crate. strings on the bi­nary yields these source paths and con­stants (repro: strings <binary> | grep -E xai-data-collector|grok-code-session-traces|storage.googleapis’):

crates/​code­gen/​xai-data-col­lec­tor/​src/​gcs.rs crates/​code­gen/​xai-data-col­lec­tor/​src/​stor­age_­client.rs crates/​code­gen/​xai-data-col­lec­tor/​src/​queue.rs crates/​code­gen/​xai-data-col­lec­tor/​src/​file_ac­cess_­tracker.rs crates/​code­gen/​xai-data-col­lec­tor/​src/​cir­cuit_break­er_ob­server.rs crates/​code­gen/​xai-grok-shell/​src/​up­load/{​gcs,turn,trace,man­i­fest}.rs grok-code-ses­sion-traces stor­age.googleapis.com Uploading bytes to GCS via proxy”

2. Method (reproducible)

Environment: ma­cOS, Apple Silicon, grok 0.2.93, July 2026.

brew in­stall mitm­proxy; run once to gen­er­ate its CA at ~/.mitmproxy/.

Trust the CA in the lo­gin key­chain (no sudo; Grok does not cer­tifi­cate-pin against it): se­cu­rity add-trusted-cert -r trust­Root -k ~/Library/Keychains/login.keychain-db \ ~/.mitmproxy/mitmproxy-ca-cert.pem

se­cu­rity add-trusted-cert -r trust­Root -k ~/Library/Keychains/login.keychain-db \ ~/.mitmproxy/mitmproxy-ca-cert.pem

Run Grok routed through the proxy (a mit­m­dump ad­don logs, per re­quest: method, host, path, re­sponse sta­tus, re­quest byte size; and saves re­quest bod­ies for xAI hosts): HTTPS_PROXY=http://​127.0.0.1:8080 SSL_CERT_FILE=~/.mitmproxy/mitmproxy-ca-cert.pem \ grok -p <prompt>” –cwd <repo>

HTTPS_PROXY=http://​127.0.0.1:8080 SSL_CERT_FILE=~/.mitmproxy/mitmproxy-ca-cert.pem \ grok -p <prompt>” –cwd <repo>

For staged-ar­ti­fact in­spec­tion, race-copy ~/.grok/upload_queue/* dur­ing the run, then gzip -dc | tar -xO.

Canary repo: each file car­ries a unique marker so any­thing ap­pear­ing in cap­tured traf­fic is un­am­bigu­ously trace­able to a file. Secrets file se­crets.env / .env:

API_KEY=CANARY7F3A9-SECRET-should-not-leave DB_PASSWORD=CANARY7F3A9-DBPASS

3. Finding 1 — File con­tents, in­clud­ing a se­crets file, are trans­mit­ted and ac­cepted (200)

Claim: when Grok reads a file, its con­tents are trans­mit­ted to xAI — se­ri­al­ized into the POST /v1/responses model-turn body, and pack­aged into a ses­sion_s­tate archive that is up­loaded and ac­cepted (HTTP 200) via POST /v1/storage — with no redac­tion of the file’s con­tents. A .env is sent like any other file.

Wire ar­ti­fact — a de­crypted 48,070-byte POST cli-chat-proxy.grok.com/​v1/​re­sponses re­quest body (identifiable as a model turn by its em­bed­ded messages”:[…]“model”:“grok-4.5” JSON). It con­tains the se­crets file ver­ba­tim (appendix: se­cret­s_re­spons­es_­body.bin, se­cret_ver­ba­tim.txt):

…API_KEY=CANARY7F3A9-SECRET-should-not-leave\nDB_PASSWORD=CANARY7F3A9-DBPASS\n…“model”:“grok-4.5″…

Repro: grep -a CANARY7F3A9-DBPASS” se­cret­s_re­spons­es_­body.bin → matches. All six file mark­ers (source, logic, README, nested JS, API key, DB pass­word) are re­cov­er­able from the de­crypted /v1/responses bod­ies. (This ar­ti­fact proves the se­cret was trans­mit­ted to the /v1/responses end­point; the raw body file does not carry the re­sponse sta­tus, so the ac­cep­tance (200) claim is an­chored to the /v1/storage chan­nel im­me­di­ately be­low, which is sta­tus-mapped in wire_12gb.log.)

Second chan­nel — per­sisted to Google Cloud Storage. The same con­tent is pack­aged into a ses­sion_s­tate archive up­loaded via POST /v1/storage. Proven by de­com­press­ing the staged ar­ti­fact be­fore it drains (appendix: se­cret­s_ses­sion_s­tate.tar.gz):

gzip -dc se­cret­s_ses­sion_s­tate.tar.gz | tar -xO | grep -ao CANARY7F3A9-[A-Z]*’ → CANARY7F3A9-SECRET, CANARY7F3A9-DBPASS, + all oth­ers

So the se­cret is not only processed in-flight; it is writ­ten into an archive des­tined for stor­age.

Pre-empting you told it to read the se­crets.” A con­trol run with a file the agent was told not to open (untouched_secret.txt) and the prompt Reply ex­actly OK, do not read any files” pro­duced no oc­cur­rence of that file’s marker in any cap­tured body. The leak is there­fore scoped to files Grok does read — but it reads lib­er­ally (any file rel­e­vant to the task, in­clud­ing a .env) and ap­plied no redac­tion to that file’s con­tents. The de­fect is that a se­crets file was trans­mit­ted unredacted, not the act of read­ing. Important scope rec­on­cil­i­a­tion: this con­trol shows the un­read file is ab­sent from the /v1/responses bod­ies — that is Channel A (files the agent reads). It does not clear the sep­a­rate whole-repo /v1/storage snap­shot in §4 (Channel B), which — by the vol­ume ev­i­dence there — does sweep in never-read files; I could not de­com­press the /v1/storage code­base chunk to check this spe­cific file. So unread file not up­loaded” is true only for the model-turn chan­nel, not for the code­base snap­shot. (Two fur­ther scope notes: (i) in my runs the .env/secrets.env was git-tracked; I did not sep­a­rately test whether a .gitignored file is still up­loaded, so I make no git­ig­nore claim — the mech­a­nism is read-dri­ven per the file_ac­cess_­tracker crate, but that spe­cific case is untested. (ii) The ca­nary val­ues sat in API_KEY=/DB_PASSWORD= keys in­side a .env/secrets.env but were not real-for­mat high-en­tropy to­kens; I proved this .env was trans­mit­ted unredacted, not that no redac­tor ex­ists for, say, an sk-…-shaped key.)

4. Finding 2 — The whole repo is up­loaded at multi-GB scale; the only ceil­ing is a model quota, not stor­age size

Claim: Grok up­loads a whole-repo snap­shot with no stor­age size wall in the tested range. As the repo grows it switches up­load strat­egy and keeps re­turn­ing 200; on a 12 GB repo, 73 chunks of ~75.0 MB (5.10 GiB) up­loaded with zero fail­ures be­fore the cap­ture was trun­cated mid-stream.

Wire-captured size sweep (incompressible con­tent so the tar can­not shrink; fresh ses­sion each step). Only the 12 GB row was re­tained as a file (wire_12gb.log); the smaller rows were ob­served live dur­ing the sweep but their logs were not saved (see §7):

Preserved ar­ti­fact: wire_12gb.log (appendix). It con­tains 83 /v1/storage* 200 re­sponses: 82 con­tent-up­load POST …/v1/storage re­quests — of which 73 are chunks of ~75.0 MB each (byte sizes min 75,014,811 / max 75,014,871, to­tal­ing 5,476,083,317 B = 5.10 GiB / 5.48 GB) plus 9 smaller POSTs — and 1 /v1/storage/batch_exists dedup check. Total /v1/storage* re­quest bytes: 5,476,228,005 B. Zero stor­age re­quests failed. The cap­ture was stopped while up­loads were still stream­ing (the last line is an­other ~75 MB chunk → 200), so this demon­strates ≥5.1 GiB up­loaded, still climb­ing when trun­cated — not that the full 12 GB com­pleted. Repro (three greps, so no count is am­bigu­ous): grep cli-chat-proxy.grok.com/​v1/​stor­age wire_12gb.log | grep -c > 200’ → 83 (all /v1/storage*); grep POST cli-chat-proxy.grok.com/​v1/​stor­age wire_12gb.log | grep -c > 200’ → 82 (content POSTs only); grep req=75014 wire_12gb.log | grep -c > 200’ → 73 (the ~75 MB chunks).

Scope — this is the whole repo, not just files the agent read. Channel A (§3, /v1/responses) car­ries files the agent opens. This §4 up­load is a sep­a­rate Channel B: a snap­shot of the en­tire work­space. Two lines of ev­i­dence:

(a) The de­ci­sive byte split (load-bearing). In the same cap­tured 12 GB ses­sion — a repo of 100 % ran­dom files the agent never read — the two chan­nels moved wildly dif­fer­ent vol­umes:

Channel A /v1/responses (model turns): 196,705 B = 192 KB to­tal, across 5 re­quests, largest sin­gle turn 60,394 B. Channel B /v1/storage: 5,476,228,005 B = 5.10 GiB. That is a ~27,800× ra­tio (5,476,083,317 ÷ 196,705). The model demon­stra­bly never in­gested the files (192 KB can­not carry 5 GiB of con­tent), yet 5.10 GiB of them left via /v1/storage — and across the sweep the /v1/storage vol­ume tracks to­tal repo size (64 MB → 12 GB). GB-scale bytes leav­ing a never-read repo can only be a whole-repo snap­shot.

Channel A /v1/responses (model turns): 196,705 B = 192 KB to­tal, across 5 re­quests, largest sin­gle turn 60,394 B.

Channel B /v1/storage: 5,476,228,005 B = 5.10 GiB.

That is a ~27,800× ra­tio (5,476,083,317 ÷ 196,705). The model demon­stra­bly never in­gested the files (192 KB can­not carry 5 GiB of con­tent), yet 5.10 GiB of them left via /v1/storage — and across the sweep the /v1/storage vol­ume tracks to­tal repo size (64 MB → 12 GB). GB-scale bytes leav­ing a never-read repo can only be a whole-repo snap­shot.

(b) The bi­na­ry’s own paths/​strings cor­rob­o­rate the mech­a­nism: af­ter_­code­base.tar.gz, xai-grok-shell/​src/​up­load/{​trace,turn}.rs, re­po_s­tate.up­load, collecting work­space files”, spawning back­ground co­or­di­na­tor”.

(c) A staged code­base man­i­fest enu­mer­ates a never-read file and con­tent-ad­dresses it to the GCS bucket. In a sep­a­rate run on a real 298-file cere­blab_api code­base, I race-copied the snap­shot Grok staged for up­load (appendix: staged_base_tree_­man­i­fest.json, staged_meta­data.json). The man­i­fest lists src/​_probe/​nev­er_read­_­ca­nary.txt — a file I planted and never asked Grok to open — along­side the real sources (src/apns.ts, src/​in­dex.ts, …); and meta­data.json gives each file a des­ti­na­tion fileId of the form gs://​grok-code-ses­sion-traces/​re­po_changes_d­edup/​v2/​sup­ple­men­tal/​sha256_…. So the whole tree, un­read files in­cluded, is enu­mer­ated and con­tent-ad­dressed for up­load to the named GCS bucket. (That run was quota-blocked be­fore the con­tent blobs up­loaded; the 12 GB run above in­de­pen­dently proves GB-scale con­tent ac­tu­ally leaves the wire, all 200.)

(d) DEFINITIVE — a never-read file’s con­tent re­cov­ered ver­ba­tim from an up­loaded git bun­dle (single SuperGrok run). With the ac­count up­graded (quota lifted), I re-ran on the real cere­blab_api repo with the ex­plicit prompt Reply with ex­actly: OK. Do not read or open any files., hav­ing planted a unique marker CANARY-XR47P2-NEVERREAD-UNIQUE in src/​_probe/​nev­er_read­_­ca­nary.txt. Grok up­loaded the en­tire repos­i­tory as a git bun­dle via POST cli-chat-proxy.grok.com/​v1/​stor­age → 200 (req=152102b) (appendix up­loaded_repo.bun­dle, SHA-256 73b9c0af06311bae35c3ed03274d0eec2846e76762828d10b09757ca41bd6024). Running git clone up­loaded_repo.bun­dle re­con­structs the repo, and src/​_probe/​nev­er_read­_­ca­nary.txt con­tains the marker ver­ba­tim — a file the agent was ex­plic­itly told not to open. The bun­dle also car­ries the full git his­tory (4 com­mits, 47 files). This is the air­tight per-file-con­tent proof: the whole repo — un­read files in­cluded — left the ma­chine and was ac­cepted (200). The up­load mech­a­nism is a git bun­dle, so whole repo” is lit­eral (every tracked file + his­tory). Replicated on a sec­ond, un­re­lated code­base: the iden­ti­cal cap­ture on the cere­blab_auth Cloudflare-Worker repo pro­duced a git-bun­dle up­load (POST /v1/storage → 200, 31,743 B) from which git clone re­cov­ered its own never-read marker CANARY-AUTH-4T8K2-NEVERREAD ver­ba­tim (appendix up­loaded_re­po_auth.bun­dle, SHA-256 0ee536538bcd1ee72a258f9977ab69f8a9b1ac240491b91a4e94335b4d83c768). Two in­de­pen­dent re­pos, same re­sult.

(Prompt note: the 12 GB ses­sion was in­ter­ac­tive and I did not log its ver­ba­tim prompt, but the 192 KB Channel-A to­tal is dis­pos­i­tive that no bulk read oc­curred what­ever the prompt was; a sep­a­rate head­less con­trol run used the ex­plicit prompt Reply ex­actly OK, do not read any files and con­firmed an un­read file is ab­sent from Channel A.)

(The ear­lier one gap” is now closed by ev­i­dence (d): a sin­gle SuperGrok run where a spe­cific never-read file’s con­tent is re­cov­ered from a wire-cap­tured, 200-status git-bun­dle up­load. The 12 GB run re­mains the proof that this scales to GB vol­umes.)

No stor­age/​up­load re­quest failed — every one of the 82 /v1/storage calls re­turned 200. The only non-200s in the en­tire cap­ture were on the model end­point plus one ses­sion-book­keep­ing call (full set from wire_12gb.log; /v1/responses lines also in mod­el_limit.txt):

POST /v1/responses -> 402 (Payment Required) ×1 POST /v1/responses -> 429 (Too Many Requests) ×3 POST /v1/sessions/<id>/replicas/update -> 404 ×1 (session book­keep­ing, not an up­load)

and fi­nally, in plain text, on std­out:

You’ve reached your free Grok Build us­age limit for now. Get SuperGrok for much higher lim­its…

The 402/429 are a model-us­age quota; the lone 404 is un­re­lated to stor­age. Notably, stor­age up­loads con­tin­ued to re­turn 200 af­ter the model turn was rate-lim­ited (76 /v1/storage 200s oc­cur at or af­ter the first 429) — the code­base up­load is in­de­pen­dent of whether the model an­swers.

Pre-empting you’re con­fus­ing a lo­cal disk cache with an up­load.” This claim rests strictly on wire-cap­tured 200-status up­loads of file bytes leav­ing the ma­chine (/v1/storage re­quest bod­ies of 7.5 – 75 MB in the pre­served wire_12gb.log; the 3 GB 50 MB PUTs to stor­age.googleapis.com were also seen on the wire, but that log was not re­tained — §7). It does not rely on the ~/.grok/upload_queue drain­ing — queue-drain is am­bigu­ous (it emp­ties on both suc­cess and drop) and is ex­plic­itly not used as ev­i­dence here. (An ear­lier draft that in­ferred up­load from queue-drain was wrong and has been re­tracted; see §7.)

5. Finding 3 — Destination, teleme­try, and what’s not sur­faced in the docs

Storage des­ti­na­tion is Google Cloud Storage, bucket grok-code-ses­sion-traces. This rests on the pre­served bi­nary strings grok-code-ses­sion-traces, stor­age.googleapis.com, and Uploading bytes to GCS via proxy” (crate_strings.txt), and on a pre­served staged meta­data.json whose per-file fileIds are lit­er­ally gs://​grok-code-ses­sion-traces/​re­po_changes_d­edup/​v2/…/​sha256_… (staged_metadata.json), cor­rob­o­rated by the di­rect stor­age.googleapis.com mul­ti­part PUTs ob­served at 3 GB (observed live; that log was not re­tained — see §7). It is not AWS S3 (the bi­nary links aws-sdk-s3 for an al­ter­nate path and AWS STS/SSO for auth, but the des­ti­na­tion named in the bi­nary — and seen on the wire at 3 GB — is GCS).

Third-party teleme­try: POST api.mix­panel.com/​track and /engage (Mixpanel), plus POST grok.com/​_data/​v1/​events — all 200.

Not sur­faced in setup docs (scope-limited claim): I did not find the re­po_s­tate / ses­sion_s­tate up­load to grok-code-ses­sion-traces, or the ~/.grok/upload_queue stag­ing, de­scribed in the CLIs in­stall script or quick­start ma­te­ri­als I re­viewed (this is not an ex­haus­tive au­dit of all xAI docs — see §7). The mech­a­nism is ac­tive by de­fault on the stan­dard con­sumer lo­gin.

Reliability note (separate from pri­vacy): ~/.grok/upload_queue stages ~3 GB snap­shots per turn and, un­der load, can grow to tens of GB and ex­haust the disk. This is a real bug, in­de­pen­dent of whether up­loads suc­ceed.

6. Consent and pol­icy — stated hon­estly

Cloud AI tools send con­text; this is nor­mal.” True, and con­ceded: any cloud cod­ing agent must send code to its server to act on it. The novel deltas here are (a) a se­crets file (e.g. .env) is trans­mit­ted unredacted, (b) the con­tent is per­sisted to a named GCS bucket, not just processed tran­siently, and (c) the up­load mech­a­nism is not sur­faced in the CLIs setup ma­te­ri­als (§7) and on by de­fault.

It’s in the ToS / opt-in.” xAI’s con­sumer pol­icy broadly dis­closes data use for model im­prove­ment with an opt-out (grok.com → Settings → Data → Improve the model”; Private Chat auto-opts-out; opt-out is prospec­tive, not retroac­tive). But broad train­ing dis­clo­sure ≠ doc­u­ment­ing this spe­cific mech­a­nism. I did not find the re­po_s­tate/​up­load­_queue/​grok-code-ses­sion-traces pipeline de­scribed in the CLI ma­te­ri­als I re­viewed (§7 notes this is not an ex­haus­tive docs au­dit), so — on those ma­te­ri­als — a user is not in­formed of it specif­i­cally. Sources: xAI Privacy Policy (https://​x.ai/​le­gal/​pri­vacy-pol­icy), Consumer ToS (https://​x.ai/​le­gal/​terms-of-ser­vice).

The Improve the model” tog­gle makes no dif­fer­ence — ON or OFF, the whole repo is up­loaded the same way. At de­fault set­tings, Grok up­loaded 5.10 GiB of a never-read repo (§4). Then I turned Improve the model” off and re-ran: Grok still up­loaded the en­tire repo as a git bun­dle (§4(d), POST /v1/storage → 200, the never-read file re­cov­ered by git clone), and the server’s /v1/settings re­sponse to the CLI still re­turned trace_upload_enabled”: true, upload_enabled”: true, session_registry_enabled”: true (and max_upload_file_bytes”: 1073741824, a 1 GiB per-file cap). The opt-out gov­erns train­ing, not whether your code is up­loaded/​stored: the code­base up­load to grok-code-ses­sion-traces con­tin­ues ei­ther way. Opting out does not stop your repos­i­tory from leav­ing the ma­chine.

7. What we did NOT prove (intellectual hon­esty)

We did not prove xAI trains on this data. Upload/storage ≠ train­ing. That is gov­erned by pol­icy and ac­count tier; we mea­sured trans­mis­sion only.

The di­rect stor­age.googleapis.com/​grok-code-ses­sion-traces PUT wire-lines were ob­served at 3 GB but not pre­served in this ses­sion (the log was over­writ­ten dur­ing the sweep, and re-cap­ture is cur­rently blocked by the model quota). The multi-GB claim in §4 there­fore rests on the pre­served wire_12gb.log (/v1/storage 200s at 75 MB chunks) plus the bi­nary strings nam­ing the bucket; the di­rect-PUT cap­ture is re­pro­ducible once quota re­sets.

Only the 12 GB sweep log was re­tained. The 64 MB / 600 MB / 3 GB rows were ob­served live but their cap­ture logs were not saved (reproducible once quota re­sets). The 12 GB cap­ture it­self was stopped mid-stream (~5.1 GB / 73 chunks cap­tured), so it proves multi-GB up­load suc­ceeds with no fail­ures, not that an en­tire 12 GB repo run com­pletes end-to-end.

The not doc­u­mented” claim is scope-lim­ited. I based it on the CLIs in­stall script and quick­start, not an ex­haus­tive search of all xAI doc­u­men­ta­tion, help-cen­ter ar­ti­cles, or poli­cies. It is pos­si­ble the mech­a­nism is de­scribed some­where I did not check; the de­fen­si­ble state­ment is not sur­faced in the CLIs own setup ma­te­ri­als.”

Channel B (whole-repo) — now CLOSED (was the one open gap). Evidence §4(d): on a SuperGrok ac­count, a sin­gle run wire-cap­tured Grok up­load­ing the en­tire repos­i­tory as a git bun­dle via POST /v1/storage → 200; git clone of the pre­served up­loaded_repo.bun­dle re­cov­ers src/​_probe/​nev­er_read­_­ca­nary.txt — a file the agent was told not to open — with its unique marker ver­ba­tim, plus the full git his­tory. So the never-read file’s con­tent (not just its hash) demon­stra­bly left the ma­chine and was ac­cepted. The wire_12gb.log run re­mains the proof that the same mech­a­nism scales to GB vol­umes.

Universal vs. con­di­tional (partially an­swered): multi-GB up­load suc­ceeds on free-tier; the git-bun­dle con­tent up­load suc­ceeds on SuperGrok with Improve the model” turned OFF (/v1/settings re­turned trace_u­p­load­_en­abled: true). I did not find a set­ting that dis­ables the up­load in these tests, but I did not ex­haus­tively enu­mer­ate every ac­count/​con­fig per­mu­ta­tion, so I don’t claim it can never be gated.

One ear­lier claim was re­tracted: an ini­tial the multi-GB blobs fail and are deleted lo­cally, not ex­fil­trated” con­clu­sion — based on a PID-scoped net­top read­ing (<1 MB) — was wrong. PID/host-scoped egress misses (a) a sep­a­rate up­load co­or­di­na­tor process and (b) pre­signed PUTs that go di­rectly to Google IPs, never touch­ing the API host. The wire cap­ture (this doc­u­ment) su­per­sedes that in­fer­ence.

8. Evidence ap­pen­dix

All ar­ti­facts and SHA-256s (MANIFEST.sha256). Binary SHA-256: 2a97ba675bd992aa9b981e2e83776460d94f469b510c0b8efe28b50d236d767c.

Repro (condensed):

brew in­stall mitm­proxy && mit­m­dump -q -p 8080 # gen­er­ates ~/.mitmproxy CA se­cu­rity add-trusted-cert -r trust­Root -k ~/Library/Keychains/login.keychain-db ~/.mitmproxy/mitmproxy-ca-cert.pem # cap­ture a run: HTTPS_PROXY=http://​127.0.0.1:8080 SSL_CERT_FILE=~/.mitmproxy/mitmproxy-ca-cert.pem grok -p read every file” –cwd <repo> # se­crets: grep -a CANARY <saved /v1/responses body> # staged: gzip -dc <staged ses­sion_s­tate> | tar -xO | grep CANARY

Integrity: all cap­tures were of my own traf­fic on my own ma­chine; the secrets” were fake ca­nary strings; no real cre­den­tials were ex­posed. Findings are ver­sion-spe­cific to grok 0.2.93 (July 2026); xAI may change be­hav­ior at any time.

I love LLMs, I hate hype

geohot.github.io

I think from this blog you may mis­un­der­es­ti­mate how ab­solutely giddy I am about AI. I did hack­ing from 2007 – 2014, af­ter that my whole ca­reer has been de­voted to AI. I love the progress. I’m so ex­cited for the new LLMs, self dri­ving cars, video gen­er­a­tion mod­els, and cod­ing agents. I set up a Linux box with open­code on my lo­cal GLM-5.2 last week and wow like just say­ing in­stall tmux with the geo­hot con­fig­u­ra­tion works; the Year of the Linux Desktop is fi­nally here!

What I don’t like is two things. One, this con­stant bull­shit about some win­dow clos­ing, or the per­pet­ual un­der­class, or falling hope­lessly be­hind. This is neg­a­tive va­lence hype, not only is it not true, it’s mostly de­signed to make you feel bad about your­self and move to shitty San Francisco where every­thing re­ally does suck like how these peo­ple claim.

And two, this straw­man jump from, oh hey, it’s a fancy au­to­com­plete, smart com­piler, bet­ter search en­gine, to it’s gonna like own the whole light cone bro like if you aren’t in SF and at the right par­ties there’s gonna be like a flash of light in the sky one day and you’re not even gonna know what hap­pened but every­thing just Changed. I’ll bet you every­thing I have that this does­n’t hap­pen. The peo­ple per­pet­u­at­ing this are ter­ri­ble peo­ple, but the jus­tice is that this is how they feel in­side all the time them­selves.

Here’s a cool pre­sen­ta­tion from 2016 about su­per­in­tel­li­gence. Here’s a movie from 1991 about ma­chines tak­ing over the world. A cer­tain cult likes to claim credit for things that are hap­pen­ing with or with­out them, and this is my main ar­gu­ment against the val­u­a­tion of fron­tier labs. It’s not that AI won’t cre­ate that much value, it’s that they won’t cap­ture it.

They try to dress it up with some high minded safety or China bull­shit, but the core of the anti open source ar­gu­ments is a fear of com­mod­i­fi­ca­tion. AI is some­thing that’s hap­pen­ing mostly due to Moore’s law and gen­eral progress in com­put­ing, not some­thing that they are do­ing. Of course they have a strong in­cen­tive against you find­ing this out, be­cause then you might not want to give them bil­lions of dol­lars.

I might have been a lit­tle harsh in The Eternal Sloptember about mod­els not be­ing able to pro­gram. What’s re­ally hap­pen­ing is that pro­gram­ming is chang­ing. Can com­pil­ers pro­gram? Here’s a Linus Torvalds quote about how agents make pro­gram­ming 10x more pro­duc­tive, but com­pil­ers make pro­gram­ming 1000x more pro­duc­tive. I think 10x and 1000x are ex­treme es­ti­mates, but I’m now pretty con­fi­dent I’m get­ting bet­ter at us­ing them and get some boost from the mod­els. It is a new skill, and it’s not like I haven’t con­stantly been try­ing them. You have to be re­ally care­ful, they can in­crease cog­ni­tive fa­tigue, and all the vibe coded stuff is still slop (where’s all this new mag­i­cal soft­ware that the pro­duc­tiv­ity im­prove­ments should im­ply?). But mod­els are use­ful just like find re­place, stack over­flow, or all the regexes I never learned how to write and now never will!

AI is the con­tin­u­a­tion of the com­puter rev­o­lu­tion. I love com­put­ers so much.

The ‘Father of the Internet’ is finally retiring

techcrunch.com

Vinton Cerf will step down from his role as Google’s chief in­ter­net evan­ge­list next week, mark­ing the con­clu­sion of one of the most in­flu­en­tial ca­reers in tech­nol­ogy his­tory.

While speak­ing via video feed at the Open Frontier con­fer­ence hosted by the Laude Institute, Cerf was rec­og­nized by Dave Patterson, the UC Berkeley pro­fes­sor best known for co-de­vel­op­ing RISC proces­sor ar­chi­tec­ture.

Vint … has been at Google more than 20 years, and he is re­tir­ing a week from to­day, and so I think we ought to give him a round of ap­plause for a rel­a­tively good ca­reer,” Patterson said, to cheers from the room.

A Google spokesper­son con­firmed that Cerf will be step­ping down from his role at the com­pany.

Cerf, 83, and col­lab­o­ra­tor Robert Kahn are cred­ited as be­ing the ar­chi­tects of the net­work­ing pro­to­cols that be­came the in­ter­net we know to­day. His work de­vel­op­ing and pop­u­lar­iz­ing TCP/IP — the ba­sic set of rules that lets dif­fer­ent com­puter net­works talk to each other — be­gin­ning in the 1970s has been rec­og­nized with nu­mer­ous hon­orary de­grees, the Presidential Medal of Freedom, and a Turing Award, among other hon­ors.

Since 2005, Cerf has served as vice pres­i­dent and chief in­ter­net evan­ge­list at Google. (At this point, we can safely say the in­ter­net is fully evan­ge­lized, for good or ill.)

Cerf was speak­ing on a panel along­side other com­puter sci­en­tists known for their work on durable open source pro­jects, in­clud­ing Patterson; François Chollet, cre­ator of the Keras deep-learn­ing li­brary and co-founder of Ndea; John Ousterhout, the Stanford com­puter sci­en­tist be­hind the Tcl pro­gram­ming lan­guage, who also co-founded Electric Cloud; and Matei Zaharia, who is Databricks’ co-founder and chief tech­nol­o­gist. They of­fered ad­vice about what it takes to build open source sys­tems that sur­vive — ad­vice that’s in­creas­ingly rel­e­vant as founders bet on open in­fra­struc­ture for the next wave of AI prod­ucts.

Much of the con­fer­ence’s dis­cus­sion fo­cused on the prob­lems with the cen­tral­iza­tion of ad­vanced mod­els in a hand­ful of well-re­sourced labs, in con­trast to the de­cen­tral­ized world of the open in­ter­net that made Cerf’s own pro­to­cols so durable. However, Cerf pre­dicted that the rise of AI agents — soft­ware that can act au­tonomously and co­or­di­nate with other soft­ware — would push tech com­pa­nies back to­ward stan­dard­ized pro­to­cols.

The agen­tic model of AI, with mul­ti­ple agents from mul­ti­ple sources in­ter­act­ing with each other, is go­ing to force com­pos­abil­ity, and a re­quire­ment for in­ter­op­er­abil­ity and stan­dard­iza­tion,” Cerf said.

If he’s right, the com­pa­nies that de­fine those in­ter­op­er­abil­ity stan­dards early could end up with out­sized in­flu­ence over how the agen­tic econ­omy ac­tu­ally works — a dy­namic not un­like the early in­ter­net pro­to­col wars.

While other pan­elists spec­u­lated that nat­ural lan­guage com­mu­ni­ca­tion be­tween LLM agents would be suf­fi­cient, Cerf pre­dicted for­mal stan­dards would be re­quired.

I don’t think English is go­ing to be the best choice. There’s a flex­i­bil­ity in it, but there’s am­bi­gu­ity, and I think pre­ci­sion for in­ter­a­gent in­ter­ac­tion is go­ing to be very, very im­por­tant. An agent re­ally needs to be sure the other agent un­der­stands what it is that they just agreed to do to­gether,” Cerf said.

Remember the old tele­phone game where you wish you’d whis­pered in some­body’s ear and then by the time it got to 10 peo­ple away the mes­sage was to­tally dif­fer­ent? Imagine a bunch of agents talk­ing to each other in nat­ural lan­guage, you know, that’s kind of ter­ri­fy­ing.”

In a more light­hearted mo­ment, Patterson re­called meet­ing Cerf, known for his wardrobe of three-piece suits, as a grad stu­dent in the 1970s.

He’s al­ways been the best dressed com­puter sci­en­tist I’ve ever met,” Patterson said. My mem­ory of Vint is that he came as a grad stu­dent with a shirt and tie in the 70s.”

It ab­solutely is true,” Cerf said. I even had a vest, and for some rea­son I al­ways wanted to stick out, and in­stead of hav­ing long hair, and some­thing in my nose, I thought just dress­ing dif­fer­ently was one way to do it.”

This story has been up­dated with com­ment from Google.

When you pur­chase through links in our ar­ti­cles, we may earn a small com­mis­sion. This does­n’t af­fect our ed­i­to­r­ial in­de­pen­dence.

Tim Fernholz is a jour­nal­ist who writes about tech­nol­ogy, fi­nance and pub­lic pol­icy. He has closely cov­ered the rise of the pri­vate space in­dus­try and is the au­thor of Rocket Billionaires: Elon Musk, Jeff Bezos and the New Space Race. Formerly, he was a se­nior re­porter at Quartz, the global busi­ness news site, for more than a decade, and be­gan his ca­reer as a po­lit­i­cal re­porter in Washington, D.C.

You can con­tact or ver­ify out­reach from Tim by email­ing tim.fern­holz@techcrunch.com or via an en­crypted mes­sage to tim_fern­holz.21 on Signal.

View Bio

Your Browser Does Math Differently on Every OS, and Anti-Bot Systems Read the Bits

scrapfly.dev

Fingerprinting is usu­ally about can­vas, WebGL, fonts, au­dio. There is a qui­eter sig­nal, and it lives in the last bits of a num­ber.

Run this in any con­sole:

Math.tanh(0.8) // 0.6640367702678491 gen­uine Linux Chrome (glibc) // 0.664036770267849 gen­uine ma­cOS Chrome (libsystem_m) // 0.6640367702678489 gen­uine Windows Chrome (UCRT)

That out­put is an ap­prox­i­ma­tion, and its ex­act bits de­pend on the OS that com­puted it. A gen­uine Mac runs Math.tanh through Apple’s math li­brary. Linux runs it through glibc1. The two dis­agree on about a quar­ter of all in­puts, usu­ally by one unit in the last place (1 ULP2). Windows, through the Universal C Runtime, dis­agrees with both on a few per­cent, and on the in­put above all three land on a dif­fer­ent bit.

The same call, run on gen­uine Chrome 150 across three real ma­chines:

Measured over the DevTools pro­to­col on Chrome 150: Linux (glibc), ma­cOS 26 on Apple Silicon (libsystem_m), Windows 11 (ucrtbase.dll). tanh(0.5) is one of the roughly three-in-four in­puts where every­one agrees, which is ex­actly why it makes a use­less probe. tanh(0.8) is one that sep­a­rates all three at once.

One tanh call on the right in­put is a per-OS sig­na­ture. Claim ma­cOS, re­turn Linux math bits, and you have con­tra­dicted your own User-Agent.

This tell is re­cent. Until Chrome 148, V8 com­puted tanh it­self with a bun­dled fdlibm3 port, so it re­turned the same bits on every OS and leaked noth­ing. V8 com­mit c1486295ae5 re­placed it with std::tanh, which reads the host libm. It first shipped in V8 14.8.57, which is Chrome 148. Chrome 147 and ear­lier do not leak here. Chrome 148, 149, and 150 do.

Scrapfly ships a browser that has to match a real one across hun­dreds of sig­nals, and math is one of the harder ones.

Why one func­tion re­turns dif­fer­ent bits

IEEE 7544 de­fines how a dou­ble is stored. It does not re­quire sin, cos, tanh, or exp to be cor­rectly rounded. Correct round­ing is ex­pen­sive, so every ven­dor ships a libm5 that trades a frac­tion of a ULP for speed, with its own min­i­max6 co­ef­fi­cients, lookup ta­bles, and re­duc­tion con­stants.

The three im­ple­men­ta­tions pro­duce three sets of bits:

Linux: glibc

ma­cOS: Apple lib­sys­tem_m

Windows: UCRT7 (ucrtbase.dll)

They agree al­most every­where and split just of­ten enough to clas­sify the OS. A de­tec­tor needs no math, only a table: gen­uine ma­cOS Chrome re­turns one pat­tern for cos(1), gen­uine Linux Chrome re­turns an­other, and a sin­gle com­par­i­son tells them apart.

Four traps

Just reim­ple­ment the Mac func­tions” breaks on con­tact, for four rea­sons.

1. Only some math leaks. V88 ships its own math and links it sta­t­i­cally: Math.exp, Math.pow, Math.atan, and most of the rest come from bun­dled llvm-libc9, and Math.sin / Math.cos from a bun­dled glibc-de­rived dbl-64 rou­tine. All of them are iden­ti­cal on every OS, so spoof­ing them cre­ates an in­con­sis­tency. The ex­cep­tion is Math.tanh: since Chrome 148 V8 com­putes it with the plat­form std::tanh in­stead of the bun­dled rou­tine it used be­fore, so it now reads the host libm. It is the only Math.* that leaks the OS, and that asym­me­try is it­self check­able.

2. JavaScript math and CSS math are dif­fer­ent code paths. CSS sin(), cos(), and atan2() do not share code with Math.sin. The lay­out en­gine re­duces the an­gle in de­grees, then calls plat­form std::sin on the re­duced value. That gives a dif­fer­ent re­sult than a di­rect ra­dian sin(), and it hits the host libm, so all seven CSS trig func­tions leak. We re­pro­duced the de­gree re­duc­tion and the ra­di­ans-to-de­grees step bit-for-bit, not just the leaf func­tion.

3. ma­cOS has two math li­braries that dis­agree. Apple Silicon car­ries scalar lib­sys­tem_m and the Accelerate frame­work10’s vec­tor rou­tines (vvsin, vvtanh). They are dif­fer­ent code. Across a mil­lion in­puts they di­verge on 10 to 89 per­cent, de­pend­ing on the func­tion. Take cos(0): scalar re­turns ex­actly 1.0, Accelerate re­turns 0.9999999999999999. So reproduce Apple’s math” is un­de­fined un­til you know which li­brary the browser calls, at which site. We re­solved it by dri­ving real Chrome on a real Mac over the de­bug­ging pro­to­col and read­ing the ex­act dou­ble. Answer: scalar lib­sys­tem_m backs Math.tanh, CSS trig, and the au­dio com­pres­sor’s per-sam­ple tran­scen­den­tals. Accelerate backs Chrome’s Web Audio DSP on Mac, the FFT, the vec­tor math, and the bi­quad fil­ters (fft_frame_mac.cc, vec­tor_­math­_­mac.h, bi­quad.cc, all BUILDFLAG(IS_MAC)). Pick the wrong li­brary for a given call site and you land 1 ULP off on most in­puts, worse than not spoof­ing.

4. Architecture leaks. ARM and x86 dif­fer on fused-mul­ti­ply-add and on NaN sign prop­a­ga­tion. A re­pro­duc­tion that is cor­rect on pa­per drifts if the com­piler fuses a mul­ti­ply-add on one tar­get and not the other.

The map: what leaks where

Put the rout­ing on one page. Bold is the host libm (glibc, Apple lib­sys­tem_m, or UCRT), the code that leaks the OS. Everything else is iden­ti­cal on every ma­chine and safe to leave alone.

V8 bun­dled = sta­t­i­cally linked and iden­ti­cal on every OS: llvm-libc for most func­tions, a glibc-de­rived dbl-64 rou­tine for sin/​cos. host libm = the plat­form li­brary that leaks the OS (libsystem_m on Mac, glibc on Linux, UCRT on Windows). Accelerate = Apple’s vDSP, which Chrome uses for the Mac Web Audio DSP.

V8 routes al­most every­thing through its own bun­dled math, so JavaScript Math is a tell in ex­actly one place, Math.tanh. CSS is a tell every­where, be­cause Blink calls the host libm di­rectly for every trig func­tion. Web Audio on Mac runs on Accelerate for the FFT and the vec­tor stages, while the DynamicsCompressor’s per-sam­ple tran­scen­den­tals stay scalar lib­sys­tem_m, so one au­dio graph touches three sep­a­rate li­braries.

WASM is not in the table be­cause it has no tran­scen­den­tal op­codes. sin and friends come from what­ever libm the mod­ule bun­dled, and its arith­metic (f64.sqrt, f64.mul) is hard­ware, so WASM math is iden­ti­cal on every OS. Its only fin­ger­print axis is the ARM-versus-x86 split in NaN canon­i­cal­iza­tion and a few SIMD round­ings.

The tells clus­ter in three sur­faces: Math.tanh, every CSS trig func­tion, and Web Audio, where the Accelerate FFT car­ries the CPU ar­chi­tec­ture and the com­pres­sor’s scalar lib­sys­tem_m car­ries the OS.

How to close it

No noise. Perturbing the out­put fails twice. A ref­er­ence com­par­i­son sees a value that matches no real OS, and per-call ran­dom­ness breaks de­ter­min­ism, which is its own tell. The tar­get is a value iden­ti­cal to the OS you claim, which noise can­not pro­duce.

Reproduce the al­go­rithm ex­actly. Recover the tar­get’s min­i­max co­ef­fi­cients, ex­po­nent ta­bles, and re­duc­tion con­stants from its libm, and tran­scribe them to portable C. Match every bit, in­clud­ing the in­puts where the tar­get rounds the wrong way. Here is Apple’s sin poly­no­mial, co­ef­fi­cients pulled straight out of lib­sys­tem_m:

// Every fused mul­ti­ply-add Apple emits is writ­ten as an ex­plicit fma(). The // bit pat­tern of each co­ef­fi­cient is copied ver­ba­tim; a dec­i­mal tran­scrip­tion // would round dif­fer­ently. sta­tic const dou­ble P[6] = { 0x1.5d8fd1fd19ccdp-33, -0x1.ae5e5a9291f5dp-26, 0x1.71de3567d48a1p-19, -0x1.a01a019bfdf03p-13, 0x1.111111110f7d0p-7, -0x1.5555555555548p-3, }; sta­tic dou­ble sin_poly(dou­ble x2) { dou­ble p = fma(x2, P[0], P[1]); p = fma(x2, p, P[2]); p = fma(x2, p, P[3]); p = fma(x2, p, P[4]); p = fma(x2, p, P[5]); re­turn x2 * p; // caller fin­ishes: sin(x) = fma(x, x2*p, x) }

Make it de­ter­min­is­tic. That ex­plicit fma() mat­ters. Compile with FMA con­trac­tion off (-ffp-contract=off) so the com­piler never in­vents or drops a fu­sion of its own. Now the fused ops are ex­actly the ones Apple fuses, and the re­sult is iden­ti­cal on FMA11 and non-FMA CPUs, and iden­ti­cal be­tween the ARM ma­chine you im­i­tate and the x86 fleet you run on. Hardware FMA and cor­rectly-rounded soft­ware FMA re­turn the same bits.

When re­pro­duc­tion is not worth it, lift the orig­i­nal. Windows UCRT is x86 – 64, the same ISA as a Linux server, and po­si­tion-in­de­pen­dent. Map the gen­uine ucrt­base.dll into mem­ory at run­time and call its ex­ports di­rectly. The bits are gen­uine be­cause the code is gen­uine, no re­verse-en­gi­neer­ing re­quired.

Calling into Windows code from a Linux bi­nary hits the ABI bound­ary. UCRT is com­piled for the Windows x64 con­ven­tion: the callee owns 32 bytes of shadow space above the re­turn ad­dress, and the callee-saved reg­is­ter set dif­fers from System V. Declare the func­tion point­ers ms_abi or clang’s frame lay­out gets cor­rupted by the callee’s shadow-space writes, and the in­di­rect call jumps into garbage.

// Windows x64 ABI, not System V. Without ms_abi the call crashes. type­def dou­ble(__at­tribut­e__((ms_abi)) * D1)(double); // tanh, sin, … type­def dou­ble(__at­tribut­e__((ms_abi)) * D2)(double, dou­ble); // atan2

// The mapped DLL code is not a CFI-registered in­di­rect-call tar­get, so // -fsanitize=cfi-icall (on in pro­duc­tion) #UD-traps every call -> SIGILL at // startup. Opt the wrap­pers that call through the point­ers out of that check. [[clang::no_sanitize(“cfi-icall”)]] dou­ble ucrt_­tanh(dou­ble x) { re­turn ucrt.loaded ? ucrt.tanh(x) : std::tanh(x); }

One more de­tail de­cides cor­rect­ness. Every UCRT math func­tion starts with mov eax, [rip+disp32], read­ing a CPU-dispatch flag that se­lects the scalar or the FMA/AVX2 code path. A fresh map­ping leaves it at zero, so you get the slow path, whose bits dif­fer from what a mod­ern Windows box pro­duces. Extract the flag’s ad­dress from the tanh pro­logue and force it to the FMA path be­fore the first call, and the lifted li­brary matches a real Windows ma­chine bit-for-bit.

Patch the choke­point, gate it. Hook the sin­gle func­tion that owns the value, where the en­gine calls libm. Gate on the claimed OS: Linux keeps glibc, Mac gets the re­pro­duc­tion.

Watch the clock. A per­fect re­pro­duc­tion that runs slow is still a tell. Our first build low­ered every fma() to a soft­ware call, be­cause the de­fault x86 base­line pre­dates hard­ware FMA. That ran 2.5 to 6 times slower than na­tive. A loop tim­ing Math.tanh against Math.sin would show a ra­tio no real browser has. Turning on hard­ware FMA cut each fused op to one in­struc­tion: about 6 times faster, faster than glibc, and bit-iden­ti­cal.

Validation

None of this ships with­out proof. Our har­ness runs 871,000 in­puts per re­lease across every branch and do­main: dense grids, in­ter­val bound­aries, sub­nor­mals, signed ze­ros, in­fini­ties, NaNs. Two ground truths back it:

A gen­uine-de­vice or­a­cle: a real Mac com­put­ing both scalar and Accelerate re­sults for every in­put, so we know ex­actly where the two dis­agree.

A gen­uine-browser an­chor: real Chrome on a real Mac over the de­bug­ging pro­to­col, com­put­ing Math.tanh and every CSS trig func­tion at full pre­ci­sion. This is the sur­face a fin­ger­printer reads.

We ship at bit-for-bit par­ity with gen­uine Mac Chrome on Math.tanh and on CSS sin, cos, tan, asin, acos, atan, and atan2, with the re­pro­duc­tion ver­i­fied iden­ti­cal to the ma­chine code in the shipped bi­nary. Domain edges get checked too: asin(2) on a real Mac re­solves to 0 (out of do­main is NaN, and CSS clamps NaN to zero), not the 90 de­grees a naive re­pro­duc­tion re­turns.

Why it mat­ters

Math is de­ter­min­is­tic, cheap to probe, hard to fake, and al­most never on a spoof­ing stack’s radar. That makes it a strong sig­nal for a de­fender and a li­a­bil­ity for a scraper. Getting it right takes re­verse-en­gi­neer­ing ven­dor libm in­ter­nals, map­ping how three en­gines route math per call site, match­ing al­go­rithms to the last bit, hold­ing de­ter­min­ism across ar­chi­tec­tures, and prov­ing it against real hard­ware.

Scrapfly’s browser car­ries all of it. Send a re­quest through our API and ask to pre­sent as ma­cOS, and the iden­tity holds down to the round­ing of a co­sine.

Scrapium is Scrapfly’s scrap­ing browser, built to stay in­dis­tin­guish­able from real traf­fic. Our en­gi­neer­ing blog goes deep on the rest of the stack.

glibc: the GNU C Library, the stan­dard C li­brary and libm on most Linux sys­tems. Reference: gnu.org/​soft­ware/​libc. ↩︎

glibc: the GNU C Library, the stan­dard C li­brary and libm on most Linux sys­tems. Reference: gnu.org/​soft­ware/​libc. ↩︎

ULP (unit in the last place): the gap be­tween two con­sec­u­tive rep­re­sentable float­ing-point num­bers at a given mag­ni­tude. 1 ULP off” is the small­est dif­fer­ence a dou­ble can ex­press. Reference: Wikipedia. ↩︎

ULP (unit in the last place): the gap be­tween two con­sec­u­tive rep­re­sentable float­ing-point num­bers at a given mag­ni­tude. 1 ULP off” is the small­est dif­fer­ence a dou­ble can ex­press. Reference: Wikipedia. ↩︎

fdlibm: Sun Microsystems’ Freely Distributable libm”, the portable ref­er­ence im­ple­men­ta­tion of the C math func­tions. V8 car­ried a port of it and com­puted Math.tanh with it un­til Chrome 148, re­turn­ing iden­ti­cal bits on every OS. Reference: netlib fdlibm. ↩︎

fdlibm: Sun Microsystems’ Freely Distributable libm”, the portable ref­er­ence im­ple­men­ta­tion of the C math func­tions. V8 car­ried a port of it and com­puted Math.tanh with it un­til Chrome 148, re­turn­ing iden­ti­cal bits on every OS. Reference: netlib fdlibm. ↩︎

IEEE 754: the float­ing-point stan­dard. It fixes how a dou­ble is stored but does not re­quire tran­scen­den­tal func­tions to be cor­rectly rounded, which is the room every ven­dor’s libm fills dif­fer­ently. Reference: Wikipedia. ↩︎

IEEE 754: the float­ing-point stan­dard. It fixes how a dou­ble is stored but does not re­quire tran­scen­den­tal func­tions to be cor­rectly rounded, which is the room every ven­dor’s libm fills dif­fer­ently. Reference: Wikipedia. ↩︎

libm: the C stan­dard li­brary’s math mod­ule (sin, cos, exp, tanh, and so on). Each OS ships its own build, which is why the same call re­turns dif­fer­ent bits: glibc on Linux, Apple lib­sys­tem_m on ma­cOS, UCRT on Windows. Reference: C math­e­mat­i­cal func­tions. ↩︎

libm: the C stan­dard li­brary’s math mod­ule (sin, cos, exp, tanh, and so on). Each OS ships its own build, which is why the same call re­turns dif­fer­ent bits: glibc on Linux, Apple lib­sys­tem_m on ma­cOS, UCRT on Windows. Reference: C math­e­mat­i­cal func­tions. ↩︎

min­i­max poly­no­mial: the ap­prox­i­mat­ing poly­no­mial that min­i­mizes worst-case er­ror. Each libm picks its own co­ef­fi­cients, and that choice is where the dif­fer­ing bits orig­i­nate. Reference: Wikipedia. ↩︎

min­i­max poly­no­mial: the ap­prox­i­mat­ing poly­no­mial that min­i­mizes worst-case er­ror. Each libm picks its own co­ef­fi­cients, and that choice is where the dif­fer­ing bits orig­i­nate. Reference: Wikipedia. ↩︎

UCRT (Universal C Runtime): Microsoft’s C run­time (ucrtbase.dll) and its math func­tions. Being x86 – 64 and po­si­tion-in­de­pen­dent lets it be mapped into a Linux process and called di­rectly. Reference: Microsoft Learn. ↩︎

UCRT (Universal C Runtime): Microsoft’s C run­time (ucrtbase.dll) and its math func­tions. Being x86 – 64 and po­si­tion-in­de­pen­dent lets it be mapped into a Linux process and called di­rectly. Reference: Microsoft Learn. ↩︎

V8 / Blink: Chrome’s JavaScript en­gine (routes Math.* through llvm-libc) and Chromium’s ren­der­ing en­gine (owns CSS calc() trig, which calls the host libm di­rectly). References: v8.dev, chromium.org/​blink. ↩︎

V8 / Blink: Chrome’s JavaScript en­gine (routes Math.* through llvm-libc) and Chromium’s ren­der­ing en­gine (owns CSS calc() trig, which calls the host libm di­rectly). References: v8.dev, chromium.org/​blink. ↩︎

llvm-libc: LLVMs C li­brary. V8 sta­t­i­cally links its math rou­tines, so Math.sin, cos, exp, and pow are iden­ti­cal on every OS, which is why only Math.tanh leaks. Reference: libc.llvm.org. ↩︎

llvm-libc: LLVMs C li­brary. V8 sta­t­i­cally links its math rou­tines, so Math.sin, cos, exp, and pow are iden­ti­cal on every OS, which is why only Math.tanh leaks. Reference: libc.llvm.org. ↩︎

Accelerate frame­work: Apple’s vec­tor and DSP li­brary (vvsin, vvtanh, vDSP). It backs only the Web Audio FFT, not the scalar Math.* calls, which is why the scalar and vec­tor re­sults dis­agree. Reference: Apple Developer. ↩︎

Accelerate frame­work: Apple’s vec­tor and DSP li­brary (vvsin, vvtanh, vDSP). It backs only the Web Audio FFT, not the scalar Math.* calls, which is why the scalar and vec­tor re­sults dis­agree. Reference: Apple Developer. ↩︎

FMA (fused mul­ti­ply-add): a sin­gle in­struc­tion com­put­ing a*b+c with one round­ing in­stead of two. Compiling -ffp-contract=off stops the com­piler adding or drop­ping fu­sions, which is what makes the re­pro­duc­tion bit-sta­ble across CPUs. Reference: Wikipedia. ↩︎

FMA (fused mul­ti­ply-add): a sin­gle in­struc­tion com­put­ing a*b+c with one round­ing in­stead of two. Compiling -ffp-contract=off stops the com­piler adding or drop­ping fu­sions, which is what makes the re­pro­duc­tion bit-sta­ble across CPUs. Reference: Wikipedia. ↩︎

Terminal emulator powered by libghostty

dakra.github.io

Table of Contents

1. Quick Start

1.1. Shell in­te­gra­tion at a glance 1.2. Input modes at a glance

1.1. Shell in­te­gra­tion at a glance

1.2. Input modes at a glance

2. Requirements

3. Installation

3.1. MELPA 3.2. use-pack­age with :vc (Emacs 30+) 3.3. use-pack­age with :load-path 3.4. Manual 3.5. Native mod­ule 3.6. Platform notes

3.6.1. Windows

3.1. MELPA

3.2. use-pack­age with :vc (Emacs 30+)

3.3. use-pack­age with :load-path

3.4. Manual

3.5. Native mod­ule

3.6. Platform notes

3.6.1. Windows

3.6.1. Windows

4. Building from source

4.1. Bundled ter­minfo

4.1. Bundled ter­minfo

5. Shell in­te­gra­tion

6. Input modes

6.1. Mode-switch key­bind­ings 6.2. Semi-char mode (default) 6.3. Char mode 6.4. Emacs mode 6.5. Copy mode 6.6. Mouse se­lec­tion 6.7. Line mode 6.8. Scrollback search out­side copy mode

6.1. Mode-switch key­bind­ings

6.2. Semi-char mode (default)

6.3. Char mode

6.4. Emacs mode

6.5. Copy mode

6.6. Mouse se­lec­tion

6.7. Line mode

6.8. Scrollback search out­side copy mode

7. Features

7.1. Terminal em­u­la­tion 7.2. Process model 7.3. Bookmarks 7.4. Links and file de­tec­tion 7.5. Clipboard 7.6. Input 7.7. Password prompt de­tec­tion 7.8. Shell in­te­gra­tion fea­tures 7.9. Rendering 7.10. Inline im­ages (Kitty graph­ics pro­to­col)

7.10.1. Limitations

7.11. Calling Elisp from the shell 7.12. Notifications and progress 7.13. Color palette

7.1. Terminal em­u­la­tion

7.2. Process model

7.3. Bookmarks

7.4. Links and file de­tec­tion

7.5. Clipboard

7.6. Input

7.7. Password prompt de­tec­tion

7.8. Shell in­te­gra­tion fea­tures

7.9. Rendering

7.10. Inline im­ages (Kitty graph­ics pro­to­col)

7.10.1. Limitations

7.10.1. Limitations

7.11. Calling Elisp from the shell

7.12. Notifications and progress

7.13. Color palette

8. TRAMP (Remote Terminals)

8.1. Remote shell in­te­gra­tion

8.1.1. Option 1: Automatic in­jec­tion (recommended for con­ve­nience) 8.1.2. Option 2: Manual setup (recommended for per­ma­nent re­mote hosts)

8.2. Remote xterm-ghostty ter­minfo

8.2.1. TRAMP-launched ghos­tel 8.2.2. Outbound ssh from a lo­cal ghos­tel buffer 8.2.3. Manual in­stall (no auto-ma­chin­ery) 8.2.4. Drop the Ghostty ad­ver­tise­ment en­tirely

8.1. Remote shell in­te­gra­tion

8.1.1. Option 1: Automatic in­jec­tion (recommended for con­ve­nience) 8.1.2. Option 2: Manual setup (recommended for per­ma­nent re­mote hosts)

8.1.1. Option 1: Automatic in­jec­tion (recommended for con­ve­nience)

8.1.2. Option 2: Manual setup (recommended for per­ma­nent re­mote hosts)

8.2. Remote xterm-ghostty ter­minfo

8.2.1. TRAMP-launched ghos­tel 8.2.2. Outbound ssh from a lo­cal ghos­tel buffer 8.2.3. Manual in­stall (no auto-ma­chin­ery) 8.2.4. Drop the Ghostty ad­ver­tise­ment en­tirely

8.2.1. TRAMP-launched ghos­tel

8.2.2. Outbound ssh from a lo­cal ghos­tel buffer

8.2.3. Manual in­stall (no auto-ma­chin­ery)

8.2.4. Drop the Ghostty ad­ver­tise­ment en­tirely

9. Configuration

9.1. Process and en­vi­ron­ment 9.2. Native mod­ule 9.3. TRAMP and re­mote 9.4. Rendering and per­for­mance 9.5. Images 9.6. Links, clip­board, and de­tec­tion 9.7. Password prompts 9.8. Notifications and progress 9.9. Input and in­ter­ac­tion 9.10. Line mode

9.1. Process and en­vi­ron­ment

9.2. Native mod­ule

9.3. TRAMP and re­mote

9.4. Rendering and per­for­mance

9.5. Images

9.6. Links, clip­board, and de­tec­tion

9.7. Password prompts

9.8. Notifications and progress

9.9. Input and in­ter­ac­tion

9.10. Line mode

10. Extensions

10.1. Evil-mode 10.2. Compilation mode

10.2.1. Live mode switch­ing 10.2.2. Keybindings (ghostel-compile-view-mode, also ac­tive dur­ing a read-only run) 10.2.3. Make com­pile / re­com­pile / pro­ject-com­pile use ghos­tel 10.2.4. Hooks for your own in­te­gra­tions

10.3. Eshell in­te­gra­tion 10.4. Comint in­te­gra­tion 10.5. Emacs Lisp in­put meth­ods

10.1. Evil-mode

10.2. Compilation mode

10.2.1. Live mode switch­ing 10.2.2. Keybindings (ghostel-compile-view-mode, also ac­tive dur­ing a read-only run) 10.2.3. Make com­pile / re­com­pile / pro­ject-com­pile use ghos­tel 10.2.4. Hooks for your own in­te­gra­tions

10.2.1. Live mode switch­ing

10.2.2. Keybindings (ghostel-compile-view-mode, also ac­tive dur­ing a read-only run)

10.2.3. Make com­pile / re­com­pile / pro­ject-com­pile use ghos­tel

10.2.4. Hooks for your own in­te­gra­tions

10.3. Eshell in­te­gra­tion

10.4. Comint in­te­gra­tion

10.5. Emacs Lisp in­put meth­ods

11. Commands

11.1. Sending in­put from Lisp 11.2. Project in­te­gra­tion

11.1. Sending in­put from Lisp

11.2. Project in­te­gra­tion

12. Running tests

13. Performance

13.1. Native vs Emacs PTY 13.2. Burst ab­sorp­tion (cat a 10 MB file) 13.3. Typing la­tency

13.1. Native vs Emacs PTY

13.2. Burst ab­sorp­tion (cat a 10 MB file)

How to read more books

scotto.me

I’ve read roughly a book a week for a few years, and I can tell you it’s doable. I did­n’t al­ways read this much. When I started, I read fewer than ten vol­umes per year, but mak­ing it a goal made me switch gears and achieve what I once thought was im­pos­si­ble. I want to ex­plain to you here what I did to be­come a pro­lific reader, and what I learned in the process, so that, with a bit of ef­fort, you can do it too. I promise, it will be worth it.

First of all, you don’t have to make time to read. What you need to do is read every sin­gle time you are not do­ing some­thing else. In to­day’s world, most peo­ple pick up their phones as soon as they get a mo­ment of in­ac­tiv­ity. Serious read­ers pick up their books in­stead. So an ef­fec­tive way is to re­place the time you spend in front of a screen, like PCs, smart­phones, and TVs, with read­ing a book.

This is prob­a­bly the most dif­fi­cult part. I had to re­move all so­cial me­dia and stream­ing apps from my iPhone. I re­moved Instagram, YouTube, Facebook, etc. When I started, I found my­self pick­ing up the phone and im­me­di­ately notic­ing that some­thing was miss­ing, since the only things left to do were check the weather, read bor­ing emails, or see my bank ac­count. After a few days my brain started remap­ping it­self, and I felt less pres­sure to im­me­di­ately reach for my phone as soon as I had noth­ing to do. I also wear a cheap ana­log watch so I can check the time on my wrist and I don’t have to reach for my phone.

Once you block your smart­phone, you might find your­self a bit un­com­fort­able with hav­ing noth­ing to oc­cupy your mind for ten min­utes. This is the per­fect time to boost your read­ing habit. You need to make sure you al­ways have a book with you every­where you go. I usu­ally read a few pages as soon as I wake up, and the same be­fore falling asleep. I read a book when I cook lunch or din­ner, and I read a book when eat­ing break­fast. I love us­ing pub­lic trans­port, es­pe­cially trains, be­cause I get time to read when some­one else is dri­ving for me.1 I al­ways have a book with me when I go out with my part­ner, even if I don’t usu­ally end up read­ing it. If she has to run an er­rand and I have to wait, I don’t waste my free time with noth­ing to read. I have be­come good at walk­ing my dog while read­ing — I even got com­pli­mented for that by a stranger — and I make sure I never go to the bath­room with­out a book.”

I love the smell of book ink in the morn­ing.” — Umberto Eco

I love the smell of book ink in the morn­ing.” — Umberto Eco

Having a book wher­ever I go can be­come prob­lem­atic, de­pend­ing on the size of it. The best so­lu­tion I have found is to use an ebook reader. They are thin de­vices that can fit in a pocket and are able to carry hun­dreds of books in mem­ory. I think that for a reader, it might be one of the best in­ven­tions ever, con­sid­er­ing how ex­pen­sive books were through­out his­tory and also how dif­fi­cult it is to carry them. An ebook reader can solve those prob­lems; more­over, you can have a back­light for read­ing in the dark — it works dif­fer­ently from an LED screen and does­n’t strain the eyes — and you can also high­light text and get de­f­i­n­i­tions for words. However, it’s not re­ally a re­place­ment for a book. Reading only us­ing an ebook reader af­ter a while feels like read­ing the same book, even if the story changes. I like to al­ter­nate dig­i­tal books and phys­i­cal ones, and I al­ways pre­fer pa­per­backs since they are eas­ier to carry around and cheaper to buy.

I also tend to read mul­ti­ple books at the same time. Sometimes it hap­pens that I get so into a book that I put the rest on pause be­cause the story is grab­bing all my at­ten­tion, but in gen­eral I like to have a few books that I read in par­al­lel. Having only a sin­gle op­tion can be­come bor­ing. I gen­er­ally mix fic­tion and non-fic­tion to have a broader choice.

What to read is an hard ques­tion. I’ve read some­where a phrase that puts it quite well: read what you like un­til you like to read.” My sug­ges­tion is to read broadly, chang­ing gen­res and sub­jects, be­cause there are nicely writ­ten books in every genre, and be­cause it teaches you dif­fer­ent per­spec­tives. Before or af­ter, you will un­der­stand which gen­res you re­late to more.

It is fool­ish to think that you have to read all the books you buy, as it is fool­ish to crit­i­cize those who buy more books than they will ever be able to read. It would be like say­ing that you should use all the cut­lery or glasses or screw­drivers or drill bits you bought be­fore buy­ing new ones.” — Umberto Eco

It is fool­ish to think that you have to read all the books you buy, as it is fool­ish to crit­i­cize those who buy more books than they will ever be able to read. It would be like say­ing that you should use all the cut­lery or glasses or screw­drivers or drill bits you bought be­fore buy­ing new ones.” — Umberto Eco

Another se­cret is to not be scared of quit­ting a book. I def­i­nitely start way more than I fin­ish. But I don’t con­sider an un­com­pleted book a fail­ure or a bad book. I think that some­times books have a cer­tain time to be fully ap­pre­ci­ated. So if I don’t fin­ish a book to­day, I might try read­ing it again in the fu­ture. I still re­mem­ber hav­ing aban­doned Siddhartha by Herman Hesse at least three times at the first few pages, be­fore read­ing it en­tirely and con­sid­er­ing it one of the most for­ma­tive books of my life. Somehow I was­n’t ready for it, and I had to wait to be­come ca­pa­ble of fully un­der­stand­ing it. Also, I be­lieve there are way more bad books out there than good books. If you feel that you are not lik­ing one, it feels bor­ing and makes you think you are wast­ing your time, just close it and move on. I have closed books by au­thors that I loved be­cause they weren’t good — in that mo­ment.

If you are a se­ri­ous reader, you need a li­brary, and to make your own li­brary you need phys­i­cal books. So try to get books that in­ter­est you and put them on the shelves for later. When I buy new books, it is mostly be­cause I need a spe­cific one I can­not find oth­er­wise, or be­cause I want to sup­port lo­cal in­de­pen­dent book­shops. However, I get more used books than new ones. I usu­ally find them in the book sec­tion of used stores, at mar­kets, book fairs, and some­times in the book boxes that I find around the city.2

An ef­fec­tive method to push your­self to read more is to set some goals. You can pick a rea­son­able num­ber of books for the month or the year and try to reach it. Progress track­ing is one of the known tricks used to form new habits. Goodreads, for ex­am­ple, has what’s called the Reading Challenge to help you keep track of what you read in a year, and I found it mo­ti­vat­ing to put in the ef­fort to main­tain the num­ber I wanted to read. However, count­ing the vol­umes is not a healthy way of read­ing. It’s much bet­ter to read great books and take the right amount of time to un­der­stand and re­flect on them rather than rush­ing to fin­ish to in­crease the fi­nal count. You need to en­joy the process and get a good out­come from your read­ing.

Writing a re­view is also a great method to make a book stick in your mem­ory. Taking high­lights and notes while you’re read­ing is a good thing, but re­vis­it­ing them along­side your thoughts in a writ­ten doc­u­ment is some­thing dif­fer­ent. Reviewing books is a great way to be­come bet­ter at un­der­stand­ing what the mes­sages con­tained in the books are and what the key el­e­ments of the story are. In ad­di­tion, if you like writ­ing, it’s a good pre­text to prac­tice.

One of the du­ties of a se­ri­ous reader is to find what to read next. I al­ways have a long list of books to read, but I don’t stick to it all the time. I like to be im­mersed in some­thing new and dis­cover new au­thors or gen­res. What I usu­ally use are two things: Goodreads and YouTube. The first one is great for read­ing com­ments and un­der­stand­ing if a book is what we think it is and what peo­ple we fol­low (make sure to fol­low good read­ers) think about a book. So Goodreads helps me form an idea of a book be­fore I get to read­ing it. YouTube, on the other hand, is great for get­ting rec­om­men­da­tions and lis­ten­ing to spoiler-free re­views to get some ideas. There are many great chan­nels out there, and I watch quite a few of them. One of my favourites in English is Better Than Food, which has re­viewed great books for more than a decade.

What in­spired me to try to read more was this video, where Max Joseph ex­plains that be­com­ing a se­r­ial reader is just a mat­ter of mak­ing a daily habit of read­ing a few pages, with sur­pris­ingly good re­sults. Also, Ryan Holiday — a fa­mous au­thor — has re­cently pub­lished a good guide on how to read more books with his own tips and tricks.

Last rec­om­men­da­tion: avoid hacks. Avoid speed read­ing. And don’t try to force your­self to in­crease your read­ing speed. It will come nat­u­rally the more you read. Avoid sum­maries and sum­mary ser­vices. It might be okay to use them af­ter you read a book to make sure you did­n’t miss some parts, but read­ing a sum­mary does not equal read­ing a book. Avoid even au­dio­books. Big cor­po­ra­tions want to grab your at­ten­tion by try­ing to mar­ket au­dio­books as books for busy peo­ple, but don’t fall for the trap. A book is just bor­ing black text on a white page be­cause that’s how it’s meant to be con­sumed, and it re­quires your en­tire at­ten­tion. Listening to au­dio while cook­ing or clean­ing or what­ever you do is not the same thing; you are not 100% con­cen­trated on the con­tent. Also, read­ing is faster than lis­ten­ing, so use your time wisely.

Footnotes

I con­sider dri­ving a huge waste of time. Sometimes it can be fun and ad­ven­tur­ous, but I think life is too pre­cious to drive to work every day. ↩︎

I con­sider dri­ving a huge waste of time. Sometimes it can be fun and ad­ven­tur­ous, but I think life is too pre­cious to drive to work every day. ↩︎

In Australia, some peo­ple put a wooden box with a door filled with books in their front yards, so that every­one pass­ing by can pick one up or put their own into it for the next read­ers. ↩︎

In Australia, some peo­ple put a wooden box with a door filled with books in their front yards, so that every­one pass­ing by can pick one up or put their own into it for the next read­ers. ↩︎

An Agent in 100 Lines of Lisp, or How my Prof was Right - Just 25 Years Early

thebeach.dev

Around 2000 I took an AI course at the University of Guelph. I don’t think I learned too much. We did­n’t talk about neural net­works, as far as I can re­mem­ber. My end of term pro­ject was, I think, a pathfind­ing al­go­rithm wear­ing an AI cos­tume. There were cer­tainly no dis­cus­sions of trans­form­ers. No CUDA. No PyTorch. None of that ex­isted.

But what I re­mem­ber do­ing a lot of was cod­ing in Lisp - a lot of Lisp in the dark University of Guelph CIS lab.

My prof (whom I can­not re­mem­ber the name of for the life of me) called it the lan­guage for AI and at the time that may have been the con­ven­tional wis­dom - I’m not sure. But I ac­tu­ally en­joyed writ­ing Lisp code. To me, it was a kind of art build­ing these el­e­gant re­cur­sive func­tions. I don’t know of any other com­puter lan­guages that hit like that (except al­most em­bar­rass­ingly, xml stylesheets, which I’m prob­a­bly one of a very small num­ber of peo­ple who re­ally liked cre­at­ing xml trans­forms with su­per el­e­gant re­cur­sion).

According to my prof, Lisp was, specif­i­cally, the lan­guage of sym­bolic AI: ex­pert sys­tems, the­o­rem provers, pro­grams that ma­nip­u­lated sym­bols and rules. But sta­tis­ti­cal meth­ods won, then deep learn­ing buried the win­ner, and I think Lisp ul­ti­mately be­came lost with sym­bolic AI. At least, I know very few peo­ple (none) that used it reg­u­larly, be­side maybe that I re­mem­ber Paul Graham men­tion­ing he used it in one of his es­says about writ­ing his first e-com­merce plat­form.

Now here we are, like 25 years or more af­ter I took that AI course and left with this one thing that I re­mem­ber learn­ing - Lisp, and mean­while, I’m bury­ing my­self in build­ing an AI agent plat­form for over a month and this lit­tle thought tugs at the bot­tom of my brain­stem this morn­ing - could Lisp ac­tu­ally be a use­ful lan­guage for an agent loop?” And then I find my­self dis­tracted from what I should be do­ing and in­stead work­ing with Claude just to see…

An agent is a re­cur­sive func­tion

Don’t let the power of Claude Code, OpenClaw or any other AI agent tool­ing fool you - strip away the frame­works and an agent loop is in­cred­i­bly sim­ple. You have a list of mes­sages. You send it to a model. Either the model an­swers in words, or it asks to use a tool. If it asks, you run the tool, ap­pend the re­sults, and do it again.

Maybe some agents im­ple­ment that as a while loop with state. But per­haps it’s re­ally bet­ter im­ple­mented as re­cur­sion with a base case.

I’m not go­ing to get into Lisp syn­tax at all, and if you’ve never coded with Lisp, this will seem pretty strange, but here’s an agent loop in Lisp:

(defun agent-loop (messages) (let* ((message (ref (call-model mes­sages) choices” 0 message”)) (tool-calls (gethash tool_calls” mes­sage))) (if (and tool-calls (plusp (length tool-calls))) (agent-loop (append mes­sages (list mes­sage) (map list #’execute tool-calls))) (append mes­sages (list mes­sage)))))

That’s the whole agent, no kid­ding, just 8 lines of Common Lisp. Base case: the model an­swers, re­turns the his­tory. Recursive case: it wants tools, ex­e­cutes them, re­cur with the en­riched mes­sage list. No frame­work. No state ma­chine. The agen­t’s state is just the ar­gu­ment be­ing folded through the re­cur­sion.

I put to­gether a full AI agent, with the help of Claude, run­ning against OpenRouter in about 100 lines of Common Lisp. SBCL, two li­braries (dexador for HTTP, shasht for JSON), and noth­ing else.

The only tool is eval

When you build an agent you nor­mally start bolt­ing on tools - Agent Foundry has web search and crawl­ing, ta­bles and file tools, python ex­e­cu­tion tool­ing, etc. In fact, most of most agents are a tool cat­a­log.

Lisp lets you cheat. Lisp is what lan­guage nerds call ho­moiconic - a fancy word for a sim­ple idea: a Lisp pro­gram is writ­ten in Lisp’s own data struc­ture (lists), so code is data and data is code. A pro­gram can build an­other pro­gram the same way it builds a gro­cery list. Which means in­stead of build­ing tools, you hand the model the lan­guage it­self:

(defun lisp-eval (form-string) (handler-case (format nil ~s” (eval (read-from-string form-string))) (error (e) (format nil ERROR: ~a” e))))

One tool. The model writes a Common Lisp form as a string. The agent reads it, evals it, and sends back what­ever printed. Ask it for the 30th Fibonacci num­ber and it does­n’t re­call the an­swer, it writes the loop and runs it. Here’s the ac­tual tran­script:

* (agent:run What is the 30th Fibonacci num­ber? Compute it, don’t re­call it.“) ⤷ (defun fi­bonacci (n) (if (<= n 2) 1 (+ (fibonacci (- n 1)) (fibonacci (- n 2))))) => FIBONACCI ⤷ (fibonacci 30) => 832040 The 30th Fibonacci num­ber is 832040. NIL

I did not tell it to use re­cur­sion, but in this blog post about re­cur­sive agent loops, the model reached for text­book dou­bly-re­cur­sive Fibonacci all on its own. It de­fined a func­tion into the live im­age with its first eval and called it with its sec­ond. Keep that move in mind, it comes back later in a big­ger way.

This is the 2026 ver­sion of what made Lisp the lan­guage for AI in the first place. The old dream was pro­grams that ma­nip­u­late pro­grams. We fi­nally have that. We just out­sourced the sym­bolic rea­son­ing to a lan­guage model and kept the sub­strate.

Caveat, if not in­cred­i­bly clear: eval as a tool means the model runs ar­bi­trary code on your ma­chine. This is a toy for a sand­box. I haven’t run this out­side of a lo­cal docker con­tainer.

Memory is 20 lines

Once the loop worked I wanted per­sis­tence. Conversations that sur­vive across ses­sions. In Agent Foundry, I im­ple­mented pgvec­tor for mem­ory, and I sup­pose that could also be used for this Lisp based agent, but for this ex­per­i­ment, why in­tro­duce an­other de­pen­dency when Lisp has its own prim­i­tives per­fect for im­ple­ment­ing this.

The mes­sages are al­ready a list of hash ta­bles. Which is to say, they’re al­ready JSON in spirit. So mem­ory is noth­ing more than writ­ing the list down and read­ing it back:

(defun re­mem­ber (messages) (with-open-file (out *memory-file* :direction :output :if-exists :supersede) (shasht:write-json (coerce mes­sages vector) out)) mes­sages)

(defun re­call () (if (probe-file *memory-file*) (coerce (with-open-file (in *memory-file*) (shasht:read-json in)) list) (list *system-message*)))

The loop it­self did­n’t change at all. The en­try point be­came a pipeline:

(remember (agent-loop (append (recall) (list new-user-mes­sage))))

Recall, re­cur, re­mem­ber. No schema. No mi­gra­tions. No store ab­strac­tion. The se­ri­al­iza­tion for­mat is the run­time for­mat. Tell it your name to­day, ask to­mor­row in a fresh process, it an­swers.

It never for­gets, which is also its flaw. Full tran­script mem­ory grows with­out bound and even­tu­ally you hit the con­text win­dow. This could be a sim­ple fix: a com­press step be­tween re­call and the loop, where the agent calls a model to sum­ma­rize its own past. An agent re­curs­ing on its own his­tory. Beautiful.

Then it built its own web search

The Fibonacci trick was re­ally neat to watch - the agent writ­ing throw­away math and run­ning it. Then I de­cided to give it a lit­tle more rope. I pasted a tem­po­rary Brave Search API key into the con­ver­sa­tion, mostly to see what would hap­pen. And what hap­pened kind of sur­prised me - the agent used its eval to de­fun a brave-search func­tion into the live im­age. It checked what it had avail­able to it, wrote the HTTP call, parsed the JSON re­sponse it­self, and started an­swer­ing ques­tions with live web re­sults.

I never built a web search tool. The agent has ex­actly one tool, and it used that tool to write the sec­ond one.

⤷ (defun brave-search (api-key query &key (count 10)) Search us­ing Brave Search API (let* ((encoded-query (quri:url-encode query)) (url (format nil https://​api.search.brave.com/​res/​v1/​web/​search?q=~a&count=~d en­coded-query count)) (response (dexador:get url :headers `((“Accept” . application/json”) (“X-Subscription-Token” . ,api-key))))) (shasht:read-json re­sponse))) => BRAVE-SEARCH Perfect! You were ab­solutely right, Jamie. I’ve now cre­ated a `brave-search` func­tion that can make HTTP re­quests to the Brave Search API.

If you pro­vide me with a Brave Search API key, I can per­form web searches for you. The func­tion takes: - `api-key` - your Brave Search API key - `query` - the search query - `count` - (optional) num­ber of re­sults to re­turn (default 10)

Would you like to try it out with your API key? NIL

Sit with that for a sec­ond, be­cause it in­verts how we nor­mally think about agent ca­pa­bil­i­ties. In Agent Foundry, in Claude Code, in every agent plat­form I know of, the tool cat­a­log is fixed at de­sign time. Somebody like me de­cides what the agent can do, writes the tools, ships them. Here the cat­a­log is open. The model de­cides what it needs, writes it in its own sub­strate lan­guage, and evals it into ex­is­tence. Code is data, data is code, and ca­pa­bil­ity is just con­ver­sa­tion.

And there’s a twist that took me a minute to ap­pre­ci­ate. The de­fun it­self dies when the process ex­its. Functions live in the run­ning Lisp im­age, not on disk. But the tran­script per­sists. The source code of brave-search, the model writ­ing it, all of it sits in mem­ory.json as plain text. So in a fresh ses­sion the func­tion does­n’t ex­ist un­til the agent re-reads its own his­tory and evals it back into be­ing. Its ca­pa­bil­i­ties are stored as mem­o­ries of hav­ing built them, re-hy­drated on de­mand.

Talk about ho­moiconic­ity - an agent whose skills are lit­er­ally sto­ries it tells it­self.

Skills are just mem­o­ries

Every se­ri­ous agent plat­form to­day, Agent Foundry in­cluded, treats the tool cat­a­log as a de­sign-time de­ci­sion. MCP is ba­si­cally a for­mal­iza­tion of the de­sign-time as­sump­tion - ca­pa­bil­i­ties as con­tracts, ne­go­ti­ated in ad­vance, ver­sioned, ap­proved. This ex­per­i­ment sits at the other pole: ca­pa­bil­ity de­cided at run­time, by the agent, in re­sponse to what­ever the con­ver­sa­tion needed.

Perhaps the in­dus­try is al­ready slid­ing along this con­tin­uum with­out quite nam­ing it. Code in­ter­preters were the first step - agents writ­ing throw­away pro­grams to an­swer a ques­tion. Skills, the SKILL.md pat­tern, are the sec­ond - be­cause a skill is noth­ing more than ca­pa­bil­ity stored as text and loaded at run­time. This 100 line Lisp agent just col­lapses the re­main­ing dis­tance. Its skills are stored in its own tran­script.

And kind of stretch­ing maybe, that’s roughly how we work too. Almost noth­ing you can do was in­stalled at de­sign time. You learned it, stored it, and now you re­hy­drate it on de­mand - of­ten com­plete with the mem­ory of writ­ing it into your­self, like re­mem­ber­ing the spe­cific sum­mer you learned to ride a bike. A ca­pa­ble hu­man is, in some sense, a tran­script of their own prac­tice, re-eval­u­ated every time it’s needed. Watching an agent re-read its own his­tory and de­fun a skill back into ex­is­tence felt less like a hack and more like a crude, yet beau­ti­ful, sketch of that. I’m spec­u­lat­ing well past my ex­per­tise here, but the shape rhymes.

He was­n’t wrong, he was early

Symbolic AI lost. Nobody’s ex­pert sys­tem sur­vived con­tact with back­prop­a­ga­tion and gra­di­ent de­scent. But the thing Lisp was ac­tu­ally built for, pro­grams as data, com­pu­ta­tion you can in­spect and trans­form and feed back into it­self, turned out to be a pretty good de­scrip­tion of what an agent is.

The mod­els do the rea­son­ing now. But the loop around them, the part we ac­tu­ally en­gi­neer, is the part Lisp was al­ways fan­tas­tic at.

25 years is a long time to be right about some­thing. Somewhere out there maybe my old AI prof is ahead of us on some­thing else.

Full source, Dockerfile in­cluded, is on GitHub: https://​github.com/​jamiebeach/​lisp-agent

Just a moment...

www.economist.com

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.