workerd and celld
workerd is the runtime behind Cloudflare Workers, and celld runs Workers and Durable Objects on your own storage. Both run V8, but neither can be driven like Node and Deno, for a reason specific to Workers: outside a request there is no I/O context. Code evaluated through workerd's inspector runs outside any request, so setTimeout, fetch and crypto.randomUUID() throw "Disallowed operation called within global scope".
So cells run inside a request. The kernel ships a driver Worker (runtime/workerd-worker.mjs). The host opens one WebSocket session to it and sends every cell over that session, and every cell runs in that one request's I/O context. The host side is src/workerd-target.ts (WorkerdTarget); src/workers-project.ts and src/celld-target.ts add the ways of serving the driver. For what all runtimes share, start with Runtime integrations.
The driver Worker
The driver is an ordinary module Worker with a fetch handler. When the host connects:
- It checks a per-launch token, from the
x-cdpk-tokenheader or a?token=query parameter, against itsTOKENbinding, and answers 403 if they differ. - It answers anything that isn't a WebSocket upgrade with 426.
- On the first connection it installs the
__cdpkshim, thejupyternamespace,prompt/confirmandenv. (console.log,console.errorand the rest were already replaced when the module loaded, with functions that send their text over the session.) - It creates a
WebSocketPair, accepts the server end, and returns the client end with status 101.
That request never ends while the kernel runs. Each message the host sends on it is handled inside the request's I/O context, so:
- Cells get Workers'
fetch,Request,caches, timers and randomness. - Anything a cell creates carries over to the next cell: globals, a Durable Object stub, an open stream, a timer that's still pending.
The driver's other bindings, TOKEN and META (the notebook's import.meta values) and on workerd UNSAFE, are removed from what cells see. Everything else becomes the global env, since a cell has no fetch(request, env) of its own to receive it.
The session
Both directions carry one JSON object per WebSocket message. The host sends requests with an id and an op: the same operations as Bun's channel (eval, broadcast, describe, comm, props, inspect, ping; see Bun), plus info, which returns the V8 version and the names of the bindings. The driver sends three kinds of message back:
{ kind: "reply", id, ... }when a request finishes.{ kind: "stream", name: "stdout" | "stderr", text }for eachconsolecall.{ kind: "iopub", ... }fordisplay()and comm messages.
The driver hands each request to the same runtime/evaluator.mjs the Bun target uses, so results are handles (h1, h2, ...) in place of CDP object ids, and cells come from the same transformCellForEval: top-level names become globals, and the cell runs in an async function for top-level await. Running a cell without replMode explains that transform. The evaluator runs the script with the UnsafeEval binding on workerd, since workerd forbids eval otherwise, and with plain indirect eval on celld.
Because output, display messages and replies all travel over one socket in order, no barrier markers are needed, and flush() returns at once: by the time a cell's reply arrives, its output has arrived too.
Imports: bundled by the host
workerd can't load a module at runtime beyond its builtins (node:*, cloudflare:*, workerd:*); every other module must be part of the Worker when it starts. So the host resolves and bundles a cell's imports before sending the cell:
- It finds the specifiers in the transformed cell (each
__cdpk.import("...")). - For each one it hasn't sent before,
bundleModuleruns esbuild'sbuild()onimport * as ns from "<specifier>", resolved from the notebook's directory, as an IIFE whose value is the namespace.npm:andjsr:specifiers are first installed into the npm cache, as on a Node target. - The bundle uses the conditions wrangler uses for Workers (
workerd,worker,browser). Node builtins stay external. In the driver,__cdpk.requireForimports them asnode:modules and hands them to the bundle asrequire. - The bundles travel with the
evalrequest as{ code, modules: { "<specifier>": "<bundle>" } }. The driver evaluates each one once and keeps the namespace promise, so later cells and later imports of the same specifier reuse it.
A specifier that can't be bundled becomes a bundle that rejects with esbuild's error, so the error appears in the cell that imports it. Since a failed bundle isn't remembered, it's rebuilt the next time, after you fix the cause. TypeScript files a cell imports are compiled by esbuild as part of the bundle, enum included.
Three ways to serve the driver
The host picks how to run the driver when it launches the target:
| workerd | Workers project (Miniflare) | celld | |
|---|---|---|---|
| When | cdp-workerd, no wrangler config | cdp-workerd, wrangler.jsonc, wrangler.json or wrangler.toml at or above the notebook | cdp-celld |
| Runtime | workerd serve | The project's Miniflare, through its wrangler | celld dev --no-watch |
| Session | Unix socket in a private temp directory | Miniflare's dispatchFetch | Loopback TCP |
| Token | Header | Header | Query string |
env | Empty | The project's bindings | The project's bindings, if a project |
| Code | src/workerd-target.ts (RawWorkerd) | src/workers-project.ts (MiniflareHost) | src/celld-target.ts (CelldHost) |
Each implements WorkerHost: it starts the runtime, opens the session, reports when the runtime exits, and stops it. WorkerdTarget itself doesn't know which one it has.
Plain workerd
RawWorkerd.start in src/workerd-target.ts:
- Finds
workerd: the nearestnode_modules/.bin/workerdat or above the notebook (a Workers project has one through wrangler), elseworkerdonPATH. - Reads the newest compatibility date it supports from
workerd --versionand uses that date. Below 2026-08-04 it also adds thenodejs_compatflag, which the driver needs fornode:util; from that date the flag is on by default. - Creates a temporary directory with
mkdtemp, which makes it readable only by the current user, and copies the driver's modules into it. - Writes a
config.capnpthat defines the driver Worker with these bindings:UNSAFE(unsafeEval),TOKENandMETA(both read from environment variables), and outbound network access. It serves the driver onunix:<dir>/sock. - Runs
workerd serve config.capnp --experimental(theunsafeEvalbinding is experimental) with a random 32-byteCDPK_TOKEN. - Connects to the Unix socket with a WebSocket client of its own (
src/ws-client.ts), because the WebSocket built into Node and Deno can't connect to a Unix socket. It retries until workerd is listening.
A Unix socket in a private directory means no TCP port, so other users can't reach the driver, and the token guards against other processes running as you. Whatever workerd prints itself, such as its warnings, goes to the Jupyter server log with a [cdp-kernel workerd] prefix; cell output never takes that route. The directory is removed when workerd exits.
A Workers project, on Miniflare
If a wrangler config is at or above the notebook, startMiniflare in src/workers-project.ts runs the driver on the project's own Miniflare, the simulator wrangler dev uses, so cells get the project's bindings:
- Loads
wranglerfrom the project'snode_modules, and theminiflarethat wrangler depends on, so both match the project's versions. Neither is a dependency of cdp-kernel. - Calls wrangler's
unstable_getMiniflareWorkerOptions(configPath), which turns the config into Miniflare options: bindings, compatibility settings, the path ofmain, anddefines. - Bundles the project's
mainwith esbuild intoproject.mjs. The Worker's entry module re-exportsproject.mjs's named exports, so its Durable Object classes run, takes its default export from the driver, and setsglobalThis.workerto the project's default export. - Starts Miniflare with those modules, an
unsafeEvalBinding,nodejs_compatadded unless the project chose a variant, and the token andMETAas extra bindings. State goes to.wrangler/state/cdp-kernel, besidewrangler dev's own, so the two never open the same Durable Object. Miniflare 5's renamed options are handled. - Opens the session with
dispatchFetchand anUpgrade: websocketheader, and uses the WebSocket in the response.
The first cell then names the bindings:
[cdp-kernel] Workers project /path/wrangler.jsonc: env has DB, KV, MY_DO; worker is its default exportso a cell can call await worker.fetch(new Request("https://x/"), env). If the project can't load (for example, wrangler isn't installed in it), the first cell says why and the kernel falls back to plain workerd. Miniflare listens on a loopback TCP port, so here the token is what keeps other local processes out.
celld
celld runs the same driver, with three differences from workerd that shape src/celld-target.ts:
- No
UnsafeEval. celld allows a plain globaleval, so the driver uses that, and no experimental flag is needed. - One module. celld takes a single module, so
bundleDriverbundles the driver and, in a project, itsmainintodriver.mjs, and the config setsno_bundle: true. celld doesn't need esbuild of its own. - TCP only. celld listens on TCP. The host picks a free loopback port, and since the built-in WebSocket can't set headers, the token goes in the query string.
CelldHost.start:
- Finds
celld:~/.local/bin/celld, where celld's installer puts it, since a Jupyter server'sPATHoften lacks that directory, elsecelldonPATH. - Writes
driver.mjsand a generatedwrangler.jsoncinto a private temporary directory or, in a project, into.celld/cdp-kernelbeside the project's config. The generated config copies the project's compatibility settings and bindings (vars, KV, D1, R2, Durable Objects, Queues, Workflows, services, Worker Loaders) and adds the token andMETAas vars. It's written with mode 0600 because it holds the token. - Runs
celld dev <dir> --host 127.0.0.1 --port <port> --no-watch. - Polls
http://127.0.0.1:<port>/?token=...until the driver answers 426 (it's up, and wants a WebSocket), then opens the session.
If celld reports "Address already in use", because another process took the port between the check and celld binding it, the start is retried up to three times. celld rejects wrangler.toml; a project that can't load is reported in the first cell, and the driver runs without it.
celld dev runs its worker process in a separate process group and waits for it to stop gracefully, which a busy cell never allows. So stopping kills that process first, then celld dev. The generated files are removed when celld exits; a project's state stays in .celld/cdp-kernel for the next kernel.
Interrupts
As on Bun, nothing can stop JavaScript that's running, so the host sends a ping on the session:
- If the driver answers within 500 ms, the cell was only waiting. The host abandons the cell's request and the cell ends as interrupted. The session, its I/O objects and every global stay, and whatever the cell started keeps running.
- If not, the cell is busy. The host stops the runtime (
SIGKILLfor workerd,dispose()for Miniflare, the process tree for celld) and the next cell starts a new one without the earlier state. The cell's output says so.
See Interrupts for a diagram.
Limits that follow from the design
prompt()andconfirm()returnnull. A request can't block waiting for the frontend, so they behave as when the frontend has no stdin.- No file system.
jupyter.image(path)throws; pass the image's bytes instead. - Packages must bundle for Workers. Anything with native code, or that needs Node APIs Workers lacks, won't load.
- No debugger, as on Bun.
Known gaps lists the rest, per runtime.