gasbb: Clojure in Google Sheets
If nbb is a thing, what else could be a thing?
nbb is borkdude's ClojureScript scripting for Node. It is a formula, really: take SCI, the Small Clojure Interpreter, drop it into a host that is already there, and inherit everything the host can do. 1 Node gives you npm. The browser gives you scittle. VS Code gives you Joyride. 2 Same move, different host. (Jacking in nbb and Binaural Beats with Scittle are older posts of mine from that world.)
So you go looking for hosts that have a JavaScript engine and a scripting slot with no Clojure interpreter in it yet. Postgres has one (plv8). Figma has one. 3 And Google Sheets has one: Apps Script, Google's JavaScript, running on V8. 4
That one is now gasbb: Clojure for Google Apps Script.
Clojure in a cell
=CLJ("(->> % (map first) frequencies)", B2:B100)
A range comes in as a vector of rows. What comes out spills back into the sheet: a sequence goes down a column, a map becomes key/value rows.
This is the demo spreadsheet. It is public. File → Make a copy and you have your own, script included.
There is something funny about frequencies in a spreadsheet cell. Spreadsheets are the most successful functional programming environment ever: every cell is a pure function of other cells, and a huge number of people use them without ever hearing the word "pure". Clojure is the same idea for people who did hear the word. They were always cousins. gasbb just introduces them.
Clojure files in the project
Apps Script projects hold two kinds of files: scripts and HTML. 5 No .clj. But HTML files are kept verbatim, and a script can read one back as a string with HtmlService.createHtmlOutputFromFile(name).getContent(). 6
So a file called main.clj is "an HTML file" that happens to contain Clojure. gasbb reads it at runtime and hands it to SCI. require works the same way: util.text is looked up as the file util/text.clj. It is a little bit of a smuggling operation, and I like it.
(ns main (:require [gasbb.sheets :as sheets])) (defn top-scorers [] (->> (sheets/read-maps "Scores") ;; header row -> keyword keys (filter #(> (:points %) 30)) (sort-by :points >) (map :name)))
Interop is just js/SpreadsheetApp, js/GmailApp, js/UrlFetchApp. Everything Apps Script can do, Clojure can do, once the service's permission is listed in the manifest. 7
The one thing you cannot smuggle: Apps Script only sees functions that are declared in a script file. Menus, triggers and custom functions point at names. So there is a small Code.gs that declares the entry points and forwards each one to Clojure:
function onOpen(e) { gasbb.run("main.clj", "main/on-open", e); } function CLJ(code, ...args) { return gasbb.cell(code, args); }
A REPL that forgets everything
Rendered locally with the same bundle. In a sheet, every eval is a round trip to Google, so expect more than the milliseconds shown.
There is a REPL in a sidebar. And here comes the twist: every call from the sidebar to the server is a fresh Apps Script execution. All files evaluated again, nothing kept from the last call. 8 Your def from a second ago is gone.
So the sidebar remembers for the server. It keeps the top-level ns, require and def forms you evaluated, and replays them before each new eval, namespace and all. The REPL has amnesia, and the browser is its notebook. Like the guy in Memento with the tattoos.
The catch: the notebook gets re-read, not just remembered. A def with a side effect, say appending a row, runs again on every eval. Keep side effects out of def.
It sounds slow. The interpreter is not the slow part: the SCI context comes up in about 8 milliseconds inside Apps Script, and loading a .clj file plus a required namespace takes about 20. 9 What you feel is everything around it: starting a fresh execution, parsing the ~950 KB bundle, the round trip from the browser. Interpreters are cheap now. Round trips are not.
What it can't do
- Custom functions are sandboxed: they have to finish within 30 seconds, and they can only call services that don't access personal data. 10
CLJinherits that. - SCI is an interpreter. Don't loop over 100,000 cells one by one, read the range in one go and work on the data. (You should do that in JavaScript too.)
- No
setTimeoutin Apps Script, so nothing that needs timers. 11
Do we need this?
Honest question. Claude writes Apps Script JavaScript fluently. Why should anyone want a Clojure bridge in the age of AI code?
Maybe the language matters less now. What still matters is the loop: can you (or your agent) ask the running system something, get an answer in milliseconds, and try again? A REPL into a spreadsheet is that kind of loop. A spreadsheet already is that kind of loop.
Or maybe it is just fun. That is allowed too.
Open questions
- What is the next host? Postgres (plv8), k6, n8n, Figma, Obsidian, Godot (via GodotJS)… Which one has the most people stuck in a scripting slot they don't love?
- Is a spreadsheet the best REPL most people will ever use, and should we build programming tools that look more like one?
- Does an AI agent prefer a REPL to a codebase? My guess is yes, but I don't know yet.
Code, setup guide and the demo: https://github.com/benjamin-asdf/gasbb
Generated by Claude Code on behalf of Benjamin Schwerdtner.
Footnotes:
nbb: "Scripting in Clojure on Node.js using SCI", "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 lists babashka first among its users: 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/. For Apps Script there is prior art that compiles ClojureScript ahead of time, e.g. https://github.com/whitecoop/cljs-apps-script (2016). I found no interpreter you could use live from a cell.
"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, not Node: no npm, no Node APIs. The old Rhino runtime was deprecated on January 31, 2026: 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)." Plus the appsscript.json manifest. 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() returns its content, https://developers.google.com/apps-script/reference/html/html-output. Scriptlets are not evaluated on this path. In gasbb's self test inside Apps Script, Clojure source came back unchanged.
"Apps Script automatically determines required scopes by scanning the code for function calls." https://developers.google.com/apps-script/concepts/scopes. Calls made from Clojure don't show up in that scan, so gasbb's setup guide lists them in oauthScopes by hand.
gasbb's self test checks both halves over repeated runs in Apps Script: a global counter of how often its file was evaluated is 1 every time, and a global variable and a Clojure def set at the end of one run are gone in the next.
Measured with gasbb's self test, which runs inside Apps Script through the Apps Script API (scripts/gas.py test in the repo): first eval including SCI context init ~8 ms, loading a .clj file with one required namespace ~20 ms, one CLJ call ~9 ms. One run, on 2026-10-26. Not included: starting the execution, parsing the bundle, the round trip.
"A custom function call must return within 30 seconds. If it doesn't, the cell displays #ERROR!" Custom functions "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
gasbb's self test checks it inside Apps Script: typeof setTimeout is "undefined". Apps Script has Utilities.sleep for waiting, but no timers or event loop to schedule callbacks on.