gasbb: Clojure in Google Sheets
Wenn nbb ein Ding ist, was könnte sonst noch ein Ding sein?
nbb ist borkdudes ClojureScript-Scripting für Node. Eigentlich ist es eine Formel: Man nimmt SCI, den Small Clojure Interpreter, setzt ihn in einen Host, der sowieso schon da ist, und erbt alles, was der Host kann. 1 Node bringt npm mit. Der Browser bringt scittle. VS Code bringt Joyride. 2 Gleicher Zug, anderer Host. (Jacking in nbb und Binaural Beats with Scittle sind ältere Posts von mir aus dieser Ecke.)
Also sucht man Hosts, die eine JavaScript-Engine und einen Scripting-Platz haben, in dem noch kein Clojure-Interpreter steckt. Postgres hat einen (plv8). Figma hat einen. 3 Und Google Sheets hat einen: Apps Script, Googles JavaScript, das auf V8 läuft. 4
Daraus ist jetzt gasbb geworden: Clojure für Google Apps Script.
Clojure in einer Zelle
=CLJ("(->> % (map first) frequencies)", B2:B100)
Ein Bereich kommt als Vektor von Zeilen herein. Was herauskommt, ergießt sich zurück ins Sheet: Eine Sequenz läuft eine Spalte hinunter, eine Map wird zu Schlüssel/Wert-Zeilen.
Das ist das Demo-Spreadsheet. Es ist öffentlich. Datei → Kopie erstellen, und man hat sein eigenes, mitsamt Script.
frequencies in einer Tabellenzelle hat etwas Komisches. Tabellenkalkulationen sind die erfolgreichste funktionale Programmierumgebung aller Zeiten: Jede Zelle ist eine reine Funktion anderer Zellen, und unglaublich viele Menschen benutzen sie, ohne je das Wort "pure" gehört zu haben. Clojure ist dieselbe Idee für Leute, die das Wort doch gehört haben. Die beiden waren schon immer Cousins. gasbb stellt sie nur einander vor.
Clojure-Dateien im Projekt
Apps-Script-Projekte enthalten zwei Sorten Dateien: Scripts und HTML. 5 Kein .clj. Aber HTML-Dateien werden wortgetreu gespeichert, und ein Script kann eine davon mit HtmlService.createHtmlOutputFromFile(name).getContent() als String zurücklesen. 6
Eine Datei namens main.clj ist also "eine HTML-Datei", die zufällig Clojure enthält. gasbb liest sie zur Laufzeit und gibt sie an SCI. require funktioniert genauso: util.text wird als Datei util/text.clj gesucht. Ein kleiner Schmuggel, und ich mag ihn.
(ns main (:require [gasbb.sheets :as sheets])) (defn top-scorers [] (->> (sheets/read-maps "Scores") ;; Kopfzeile -> Keyword-Schlüssel (filter #(> (:points %) 30)) (sort-by :points >) (map :name)))
Interop ist einfach js/SpreadsheetApp, js/GmailApp, js/UrlFetchApp. Alles, was Apps Script kann, kann Clojure, sobald die Berechtigung des Dienstes im Manifest steht. 7
Das Einzige, was sich nicht schmuggeln lässt: Apps Script sieht nur Funktionen, die in einer Script-Datei deklariert sind. Menüs, Trigger und eigene Tabellenfunktionen zeigen auf Namen. Deshalb gibt es eine kleine Code.gs, die die Einstiegspunkte deklariert und jeden an Clojure weiterreicht:
function onOpen(e) { gasbb.run("main.clj", "main/on-open", e); } function CLJ(code, ...args) { return gasbb.cell(code, args); }
Ein REPL, der alles vergisst
Lokal mit demselben Bundle gerendert. Im Sheet ist jede Auswertung ein Weg zu Google und zurück, also mehr als die gezeigten Millisekunden.
In einer Seitenleiste gibt es einen REPL. Und jetzt kommt der Twist: Jeder Aufruf von der Seitenleiste zum Server ist eine frische Apps-Script-Ausführung. Alle Dateien neu ausgewertet, nichts bleibt vom letzten Aufruf. 8 Das def von vor einer Sekunde ist weg.
Also erinnert sich die Seitenleiste für den Server. Sie behält die ns-, require- und def-Formen auf oberster Ebene, die man ausgewertet hat, und spielt sie vor jeder neuen Auswertung erneut ab, samt Namespace. Der REPL hat Amnesie, und der Browser ist sein Notizbuch. Wie der Typ in Memento mit den Tattoos.
Der Haken: Das Notizbuch wird nicht nur erinnert, sondern jedes Mal neu gelesen. Ein def mit Nebenwirkung, etwa einer angehängten Zeile, läuft bei jeder Auswertung erneut. Nebenwirkungen gehören nicht in ein def.
Klingt langsam. Der Interpreter ist aber nicht der langsame Teil: Der SCI-Kontext steht in Apps Script nach etwa 8 Millisekunden, und eine .clj-Datei samt einem per require geladenen Namespace braucht etwa 20. 9 Was man spürt, ist alles drumherum: eine frische Ausführung starten, das ~950 KB große Bundle parsen, der Weg vom Browser und zurück. Interpreter sind heute billig. Round Trips nicht.
Was es nicht kann
- Eigene Tabellenfunktionen laufen in einer Sandbox: Sie müssen innerhalb von 30 Sekunden fertig sein und dürfen nur Dienste aufrufen, die nicht auf persönliche Daten zugreifen. 10
CLJerbt das. - SCI ist ein Interpreter. Nicht 100.000 Zellen einzeln durchlaufen, sondern den Bereich in einem Rutsch lesen und mit den Daten arbeiten. (Das sollte man in JavaScript auch so machen.)
- In Apps Script gibt es kein
setTimeout, also nichts, was Timer braucht. 11
Braucht man das?
Ehrliche Frage. Claude schreibt Apps-Script-JavaScript flüssig. Warum sollte jemand im Zeitalter von KI-Code eine Clojure-Brücke wollen?
Vielleicht zählt die Sprache jetzt weniger. Was weiter zählt, ist die Schleife: Kann man (oder der eigene Agent) das laufende System etwas fragen, in Millisekunden eine Antwort bekommen und es nochmal versuchen? Ein REPL in ein Spreadsheet ist so eine Schleife. Ein Spreadsheet ist schon so eine Schleife.
Oder es macht einfach Spaß. Das ist auch erlaubt.
Offene Fragen
- Was ist der nächste Host? Postgres (plv8), k6, n8n, Figma, Obsidian, Godot (über GodotJS)… Wo stecken die meisten Leute in einem Scripting-Platz, den sie nicht mögen?
- Ist ein Spreadsheet der beste REPL, den die meisten Menschen je benutzen werden, und sollten Programmierwerkzeuge mehr wie eins aussehen?
- Ist einem KI-Agenten ein REPL lieber als eine Codebasis? Ich vermute ja, aber ich weiß es noch nicht.
Code, Setup-Anleitung und die Demo: https://github.com/benjamin-asdf/gasbb
Generiert von Claude Code im Auftrag von Benjamin Schwerdtner.
Fußnoten:
nbb: "Scripting in Clojure on Node.js using SCI", laut README "the same interpreter that powers babashka", https://github.com/babashka/nbb. scittle: "Execute Clojure(Script) directly from browser script tags via SCI", https://github.com/babashka/scittle. SCI nennt babashka als ersten Nutzer: https://github.com/babashka/sci
"Joyride is Powered by SCI (Small Clojure Interpreter)." https://github.com/BetterThanTomorrow/joyride
plv8: "V8 Engine Javascript Procedural Language add-on for PostgreSQL", https://github.com/plv8/plv8. Figma: "Figma plugins are written using primarily JavaScript and HTML", https://developers.figma.com/docs/plugins/how-plugins-run/. Für Apps Script gibt es Vorarbeiten, die ClojureScript vorab kompilieren, z. B. https://github.com/whitecoop/cljs-apps-script (2016). Einen Interpreter, den man live aus einer Zelle benutzen kann, habe ich nicht gefunden.
"Apps Script is now supported by the V8 runtime that powers Chrome and Node.js." https://developers.google.com/apps-script/guides/v8-runtime. V8, nicht Node: kein npm, keine Node-APIs. Die alte Rhino-Runtime ist seit dem 31. Januar 2026 abgekündigt: https://developers.google.com/apps-script/guides/v8-runtime/migration
"A script project has one or more script files which can either be code files (having a .gs extension) or HTML files (a .html extension)." Dazu das Manifest appsscript.json. https://developers.google.com/apps-script/guides/projects
createHtmlOutputFromFile "creates a new HtmlOutput object from a file in the code editor", https://developers.google.com/apps-script/reference/html/html-service; getContent() liefert dessen Inhalt, https://developers.google.com/apps-script/reference/html/html-output. Scriptlets werden auf diesem Weg nicht ausgewertet. Im Selbsttest von gasbb in Apps Script kam Clojure-Quelltext unverändert zurück.
"Apps Script automatically determines required scopes by scanning the code for function calls." https://developers.google.com/apps-script/concepts/scopes. Aufrufe aus Clojure tauchen in diesem Scan nicht auf, deshalb trägt die Setup-Anleitung von gasbb sie von Hand in oauthScopes ein.
Der Selbsttest von gasbb prüft beide Hälften über wiederholte Läufe in Apps Script: Ein globaler Zähler, wie oft seine Datei ausgewertet wurde, steht jedes Mal auf 1, und eine globale Variable und ein Clojure-def vom Ende eines Laufs sind im nächsten weg.
Gemessen mit dem Selbsttest von gasbb, der über die Apps Script API in Apps Script läuft (scripts/gas.py test im Repo): erstes Auswerten inklusive Aufbau des SCI-Kontexts ~8 ms, eine .clj-Datei mit einem geladenen Namespace ~20 ms, ein CLJ-Aufruf ~9 ms. Ein Lauf, am 26.10.2026. Nicht enthalten: Start der Ausführung, Parsen des Bundles, der Weg vom Browser und zurück.
"A custom function call must return within 30 seconds. If it doesn't, the cell displays #ERROR!" Eigene Funktionen "never ask users to authorize access to personal data. Consequently, they can only call services that don't have access to personal data." https://developers.google.com/apps-script/guides/sheets/functions
Der Selbsttest von gasbb prüft das in Apps Script: typeof setTimeout ist "undefined". Zum Warten gibt es Utilities.sleep, aber keine Timer und keine Event-Loop, auf der man Callbacks planen könnte.