Monorepo for the nibling.run platform
core docs VISION.md

nibling.run vision and decision record #

Status: draft, 2026-10-30. Decisions here are proposed until marked accepted.

The bet #

The web always wins, and realtime collaboration is the right foundation for agents. atproto is decentralized in a way that lets nibling.run be a personal view over a user's PDS, with work stored in private spaces by default.

nibling started as "CodeSandbox on atproto". Building the runtime showed it can be more than that: a collaborative operating system where atproto decides who can access what. Because the work runs in the browser, sharing is a link that another person runs in their own browser. Nobody opens a tunnel to a server process.

The platform is a web-based kernel that defaults to ESM. Node is emulated for module compatibility, but it is not the default. Each actor (a person's app, an agent, a tool) gets an isolated sandbox that holds only the files and context it needs. Code mode and nib:* imports connect a sandbox to the wider coordination environment. Chat and shell scripts are where this starts, and most interactions should move past them.

Principles #

These extend the principles in the root README.

  • Client-first, not client-only. Work runs in the browser by default and moves to a server only when it needs something a tab cannot do.
  • A capability is a mount or a nib:* import that a host grants a sandbox. Everything else is the browser.
  • Apps are sandboxes that view files. Agents edit the same files. No app needs an agent-specific API.
  • Copy the shape of agent tools that already work (agent-browser, skills, git) instead of inventing new ones.
  • Keep @nibling/kernel usable on its own. Internal tools and build tooling should embed it through a Vite plugin or a web component, with no Monaco-style setup.

Building blocks #

Name What it is
kernel Processes, a filesystem view, and module loading for one sandbox. The current @nibling/kernel.
process runtime How a process sees the world: web (ESM and web globals, the default), node (Node globals and builtins), or bash.
sandbox One origin with its own Service Worker and kernel, plus a manifest that declares its permissions and a lockfile that pins its dependencies.
placement Where a sandbox runs: the browser, or a runner service identified by a DID that runs the same JS in isolates.
mount A tree attached to a sandbox's namespace: content-addressed (read-only), an atproto repo, a Space, a git repo, or a writable overlay.
capability A nib:* module whose implementation the host provides. One definition generates the ESM API, the .d.ts, and a nib CLI command.
provider A third-party atproto service the kernel brokers, such as mail, domains, or payments.
credential broker The kernel component that holds secrets and attaches them to requests. Sandboxes never see them.
trusted chrome UI that the OS shell draws on the top-level origin, for prompts that a sandbox must not be able to fake.
link A reference to a space, a version, and the manifest the viewer accepts.
relay The realtime transport for presence and live edits, which atproto does not provide.

Decisions #

D1. Three tiers, each usable alone #

  1. @nibling/kernel: the kernel, Node compatibility, and processes on a single origin. It trusts its embedder. It has no atproto code and no coordinator.
  2. sandbox: origin isolation, the permission manifest, and mounts. The web component <nib-sandbox> is its public interface.
  3. platform: atproto, the coordinator, runners, the relay, and the default nib:* implementations.

Each tier depends only on the one below it. Internal tools can stop at tier 1 or 2.

D2. ESM and web globals are the default; Node is opt-in #

Before this change every process was a Node process: installGlobals (packages/kernel/src/runtime/globals.ts) installed process, Buffer and global in every worker, and spawn only runs node or files with a node shebang.

Change: add a runtime field to the spawn spec. (Not "personality": agents will have personalities.) A web process gets web globals and nib:*. A node process also gets Node globals. node: builtins stay importable from either runtime and load only when imported. The ESM path already uses the browser's own module loader over the Service Worker's virtual origin, so this is a change of defaults, not a rewrite.

Follow-up: rewrite the problem statement in packages/kernel/DESIGN.md, which describes "a browser-run Node-compatible runtime".

D3. Isolation comes from the origin, and permissions follow Deno's model #

packages/kernel/DESIGN.md accepts "Trusted-code execution: Workers are not a security sandbox." That stays true for tier 1. It cannot be true for sandboxes. A same-origin worker can skip the kernel and call navigator.storage.getDirectory(), IndexedDB, Cache Storage, BroadcastChannel, Web Locks, or fetch directly. The Service Worker cannot tell which actor made a fetch from a nested worker, because those requests carry an empty clientId.

The browser enforces isolation only between origins. So each sandbox gets its own subdomain on a separate registrable domain listed on the Public Suffix List, as csb.app and webcontainer.io are. That origin holds the sandbox's Service Worker, kernel, storage, and rendered UI. The iframe that isolates the actor is also the iframe that shows it, as on CodeSandbox and StackBlitz.

The permission policy has Deno's shape. Only the origin boundary is a security boundary. Inside one origin, all guest code shares one trust level: it can bypass the kernel and call the worker's own APIs, for example new Worker() from a web process. So every permission that matters is enforced either by what the origin can reach or by something outside the origin. Checks the kernel makes inside the origin are advisory. They give accurate errors, accounting, and an audit trail, and a guest that tries can get around them.

Permission Enforced by Boundary?
read, write Mounts. The sandbox origin holds only what was mounted, so there is nothing else to read. Yes
net The sandbox origin's CSP connect-src, and a Service Worker that serves only that sandbox. Yes
nib:* The coordinator, outside the origin, answers only the capability requests the manifest grants. The kernel's resolver also refuses ungranted nib:* modules, which is advisory. Yes, at the coordinator
publish, spend Trusted chrome on the top-level origin (D10). Yes
run, env, spawn Kernel syscalls. The kernel owns the process table, but a guest can start its own workers. No, advisory

Two actors that need different permissions therefore need two origins. A process's limits on CPU and memory are advisory in the same way, because the browser offers no per-origin quotas beyond terminating a worker.

When someone opens a shared link, the permission prompt shows the manifest. Granting a mount should feel like ChromeOS's "Share with Linux": its Linux container sees none of the user's files until the user shares a folder, which then appears inside the container under /mnt/chromeos. A mount grant is a folder handed to a sandbox, not a dialog about access rights.

Shared memory does not cross origins, so a sandbox cannot use another sandbox's kernel through the sync mailbox. This gives two tiers of semantics. Inside a sandbox, the existing kernel provides full POSIX and Node behavior, including fork over a shared filesystem. Between sandboxes, a coordinator mounts trees into each sandbox and syncs changes asynchronously over ports. DESIGN.md rejected per-process volumes for Node fidelity inside one process tree. That decision stays correct inside a sandbox, and volumes are the right model between sandboxes.

Probed 2026-10-30: a module worker whose script the Service Worker synthesizes enforces the Content-Security-Policy header on that response, including connect-src, in Chromium 156, Firefox 157, and WebKit 27.2. A nested worker enforces the CSP from its own synthesized response, independent of its parent. With the header stripped, the same cross-origin fetch succeeds. The probe page had no COOP/COEP, and classic workers, blob: workers, and report-only policies were not tested. So the sandbox's Service Worker can set each worker's network policy.

Probed 2026-10-30 (sandbox origins): a coordinator page on localhost embedded sandboxes on 127.0.0.1. The server logged Sec-Fetch-Site: cross-site for the iframe in every engine. In Chromium 156, Firefox 157, and WebKit 27.2:

  • The unmodified runtime booted in a cross-site iframe. It ran an ESM app that imports a local module and uses node:fs, re-ran after the coordinator called applyFiles, and served guest HTTP into a nested iframe. Each sandbox origin got its own Service Worker and kernel. Round trips from mount to result took 131 to 246 ms on the first run and 25 to 44 ms on a re-run. This was the first run of the runtime outside Chromium.
  • The iframe is cross-origin isolated when the sandbox documents send COOP same-origin, COEP require-corp, and CORP cross-origin, and the iframe has allow="cross-origin-isolated". Chromium needs the allow attribute. Firefox ignores it, and the headers alone isolate the iframe.
  • Worker script responses need COEP too. Without it Chromium refuses new Worker() with an ErrorEvent that has no message.
  • A SharedArrayBuffer mailbox with Atomics.wait works across the iframe, its worker, and a nested worker.
  • A SharedArrayBuffer posted from the coordinator into a sandbox fails with messageerror, directly or through a MessagePort. Shared memory must be allocated inside the sandbox.
  • BroadcastChannel does not deliver between sandbox origins.

Engine differences:

  • WebKit refuses an iframe served with COEP credentialless. Use require-corp.
  • In Playwright's WebKit build, OPFS fails with UnknownError even in a top-level page. Real Safari 27.0 on its default feature flags does not have this problem: OPFS writes and a worker's sync access handle work both top-level and in a cross-site iframe, and the iframe is cross-origin isolated with SharedArrayBuffer. The real-runtime sandbox probe also passed in Safari 27.0 on default flags (boot, ESM run in 151 ms, re-run after applyFiles in 110 ms, guest HTTP in 171 ms). An earlier run with extra feature flags enabled also passed. Use real Safari, not Playwright's WebKit, to test storage.
  • A synthetic probe saw Firefox's Service Worker fail to serve a nested worker's import() of a virtual module, while fetch() of the same URL worked. The runtime's own Service Worker, which also sets COEP and CORP on module responses, served nested-worker imports in Firefox without error. The difference is not explained yet.

Header support in packages/kernel/src/node/server.ts: sandbox origins send CORP cross-origin through the existing resourcePolicy option (the default is same-origin). contentSecurityPolicy() takes frames, which adds frame-src so a coordinator can frame its sandboxes, and embedders, which adds frame-ancestors so a sandbox only accepts its coordinator as a parent. The sandbox probe passed with both in Chromium, Firefox, and WebKit.

Still to verify: re-attaching after a Service Worker restart in a cross-site iframe, and persistence under storage partitioning.

Considered and rejected: Isolated Web Apps. They unlock Direct Sockets (TCP and UDP) and Controlled Frame (scripting arbitrary embedded sites), but as of 2026 those reach users only on ChromeOS. Their required CSP (script-src 'self' 'wasm-unsafe-eval' with Trusted Types) forbids new Function, which the CommonJS loader uses, and their policy requires all executable code to come from the signed bundle, which rules out guest code. Raw sockets stay a runner capability.

D4. Sandboxes contain only what the task needs #

Skills, npm dependencies, CLI tools, and base images are all the same thing: a read-only, content-addressed mount pinned in a lockfile. A skill declares the tools and nib:* modules it needs. A sandbox is the closure of its task's skills, as in Nix. Its PATH and its resolver contain only what the closure grants.

D5. The filesystem is a store plus a namespace #

The store holds records and blobs where atproto puts them. Where a record is stored decides who can see it.

/at/<did>/<nsid>/<rkey>.json        public repo; a write is putRecord
/at/<handle> -> /at/<did>           handles are symlinks, DIDs are stable
/spaces/<space>/<nsid>/<rkey>.json  private or collaborators-only (an atproto Space)
/src/tangled.org/<did>/<repo>       cloned lazily on first access
/skills/<name>                      locked, read-only
/work  /tmp                         writable session overlay

The namespace is the paths a person controls, made of symlinks into the store:

~/blog/hello-world.md   -> /at/<did>/site.standard.document/3lx...
~/drafts/next-post.md   -> /spaces/<me>/site.standard.document/3ly...
~/projects/zine/        -> /spaces/<zine-crew>/...

Precedents: Nix keeps /nix/store plus profiles of symlinks. git keeps an object store plus trees. Keybase's KBFS encodes access in the path (/keybase/private/alice,bob). AFS has a global /afs/<cell> namespace, and Plan 9 gives each process its own namespace under /n/. ChromeOS is the closest product precedent. Its Files app shows Google Drive, local storage, the Linux container, Android storage, SMB shares, and third-party providers (through the fileSystemProvider API) as one tree. Its device is a cache of the user's account, so signing in on another Chromebook brings the same environment. nibling anchors that environment to a DID and PDS the user owns, where ChromeOS anchors it to a Google account.

Consequences:

  • packages/kernel/DESIGN.md specifies the same split, with DirInode.entries mapping a Name to an Ino, but the in-memory filesystem in packages/kernel/src/kernel/fs.ts is an object graph: each DirEntry holds its children directly, and nothing identifies an object apart from its place in the tree. Give each object a stable identity (a record URI or CID) and let directories map names to identities. Then the structure merges across kernels, and renaming a file never changes the record.
  • Changing visibility moves the record, because a public repo and a Space live on different hosts. The kernel copies the record to the target store, points the link at the copy, and deletes the old record. The user sees one operation (drag between folders, or nib share). It behaves like a move across filesystems (EXDEV).
  • Symlinks, not hard links, so realpath and ls -l show any program where a file lives and who can see it.
  • The namespace is itself a record, private by default. Publishing a site publishes one tree, never the whole namespace.
  • Writes are validated against the record's lexicon. A mismatch fails with EINVAL.
  • Creating a file in a collection directory lets the kernel assign a TID as the record key and return the real name.
  • A site.standard.document can also be viewed and edited as Markdown with frontmatter. This needs a content lexicon, because content is an empty open union in the base schema.
  • Files come in two classes. Source files are small and synced live through a CRDT. Artifacts such as dependencies and build output are large, immutable, content-addressed, and never synced live.
  • OPFS becomes a cache keyed by CID. Identical files are stored once across all workspaces.

This replaces step 4 of the runtime build order as the next storage work. The journal format must be designed for sync before OPFS persistence is built on it.

Spaces: we have access to the atproto Spaces alpha (proposal 0016, alpha released 2026-08-18) and build assuming it ships. Not yet confirmed: the encryption model, and whether the spec defines how a record moves between a public repo and a Space.

D6. Runners are the server-side placement, and they run JS, not Linux #

Some work cannot run in a tab: the realtime relay, agents that run after the tab closes, firehose triggers (D14), cron and webhooks, pushing to knots over SSH, URLs that stay up, and model key custody if a user wants it. A Service Worker does not change this. Chrome stops an idle one after 30 seconds and caps each event at 5 minutes, and a background tab can be throttled or discarded. Push is the one background mechanism every engine supports, so a runner reports back by push, and the page's Service Worker wakes to sync the result.

atproto already has the pattern. Tangled's repo manifest names its CI runner by DID (spindle), knots host git data, and a Space is announced by an #atproto_space_host service entry. nibling adds a runner: a self-hostable service identified by DID that a workspace record names. nibling.run operates a default runner.

Because nibling's workloads are web-shaped (D13), a runner runs the same JS against the same nib:* interfaces, with server-side implementations. It does not need a Linux microVM per sandbox, as CodeSandbox's Firecracker fleet provides. A runner is either Deno, with --allow-* flags generated from the sandbox manifest, or V8 isolates in the Cloudflare Workers model. Isolates are dense and cheap, and they are enough isolation for JS. The syscall table is already the boundary between a process and its kernel, so a remote kernel can speak the same interface over a WebSocket.

One manifest drives both placements:

Manifest concept Browser Runner
mounts the sandbox's file tree the isolate's mounted tree
net the sandbox origin's Service Worker and CSP Deno --allow-net, or the isolate's fetch policy
isolation boundary the sandbox origin the isolate or Deno process
credentials the page's credential broker the runner's credential broker

A runner holds write tokens for the users whose work it runs. That is why runners are self-hostable and identified by DID: a user chooses whom to trust with their tokens.

nib:* modules exist only where a host provides them. In the browser the kernel's resolver serves them. A runner has to supply both the module code and the authority behind it. So each nib:* capability is an interface with two implementations: the browser kernel's, and the runner's.

On Cloudflare, Dynamic Workers (the Worker Loader binding, open beta since March 2026) fit this shape. A parent Worker loads the guest's code into its own isolate at runtime and chooses three things for it:

Worker Loader option nibling concept
modules, the loaded code's module map where the runner's nib:* modules go. They hold no authority; they call env stubs.
env, RPC stubs into the parent capabilities. The parent holds the credential broker, attaches tokens, and enforces scopes and budgets.
globalOutbound, null or a service binding used as a fetch proxy the net permission, enforced through a proxy as sandbox-runtime does

The parent Worker plays the kernel's role. Two limits follow:

  • Only web processes can run in isolates. Workers have no synchronous filesystem and no child_process, and nodejs_compat covers only part of node:. A manifest is runner-eligible when its process runtime is web and it uses nib:* and web APIs. node processes stay in the browser or need a Deno runner.

Probed 2026-10-30 with Wrangler 4.156.0 in local dev (workerd), where the Worker Loader binding works:

  • nib:at works as an import specifier, verbatim. The module map rejects it as a plain string key (Module name must end with '.js' or '.py' (or the content must be an object indicating the type explicitly)), but accepts it in the object form, modules: { "nib:at": { js: "..." } }. No specifier rewrite is needed.
  • Authority passes as a stub: the parent exports a WorkerEntrypoint, takes a loopback stub with ctx.exports.At({}), and passes it in env. The nib:at module is a factory, createAt(env), that the guest calls with its own env, so library modules need no ambient global.
  • globalOutbound: null blocks the guest's fetch() with This worker is not permitted to access the internet via global functions like fetch(). It must use capabilities (such as bindings in 'env') to talk to the outside world. Without it, the guest reached the network.

On a Cloudflare runner, nib:fs can keep its working tree in a Durable Object's SQLite, as Cloudflare's Agents Workspace does, as a cache of the PDS-backed tree. That is the role OPFS plays in the browser (D5).

D7. Kernel tools copy agent tools that already work #

  • nib:browser copies agent-browser's commands: a snapshot that returns element refs, then click @e1, fill, screenshot. snapdom renders pixels. It needs a live document, so the Service Worker injects it into the preview document the way packages/kernel/src/sw/inject.ts injects the WebSocket shim today. Cross-origin iframes come back as placeholders, so each nested sandbox runs its own injected agent and the snapshot is assembled recursively.
  • Skills are locked dependencies (D4).
  • Git is required. isomorphic-git is the default candidate in the browser. Tangled is jj-native. jj-lib compiles to WASI with one upstream patch, but its git backend (gitoxide) is untested on WASI, so jj waits on that probe.
  • Tangled repos mount lazily under /src/tangled.org/. Clones go straight from the browser to the knot, which allows cross-origin reads. Pushes go through a runner over SSH unless knots turn out to accept HTTPS push.
  • Bash stays as a process runtime. Agents are trained on shell commands, and the bashkit evaluation already exists. Bash and code mode go through the same kernel and the same permission checks, and each capability generates a nib CLI command, so neither path has more power than the other.

D8. The kernel never calls a model on its own #

nib:model defaults to OpenRouter, and spend is a capability with a budget. An agent loop is a userland program, an ESM actor in a sandbox like any other. nibling ships a default agent loop, the way Unix ships a default shell. The product can blur the line between OS and agent loop. The architecture keeps it.

D9. Providers are brokered by the kernel #

Record-native providers keep their state in the user's PDS, so they need no adapter:

  • marque.at (domains): at.marque.dns and at.marque.domain records, with atproto OAuth.
  • atmosphere.money (payments): Stripe-backed today. The broker writes receipts into the payer's repo. Checkout is documented only for pilot apps.

API-native providers keep their state on their own servers and need a mount adapter, in the way FUSE puts an API behind files. Adapters are locked packages.

  • comail.at (mail): its own relay store. The API takes the DID as the username and an API key as the password, not OAuth. An adapter can present maildir layout under ~/mail.
  • stream.place (live video): place.stream.*, one-to-many broadcast with WHEP playback. It fits a Live app. A Meet app still needs a WebRTC media server on a runner.

Prefer record-native providers when both kinds exist.

The credential broker holds API keys and OAuth sessions and attaches them to requests. A sandbox calls nib:mail.send and never sees comail's key. OAuth scopes are derived from the sandbox manifest, so the PDS enforces least privilege as well as the kernel.

D10. Trusted chrome for irreversible actions #

Purchase, publish, domain-transfer, and permission prompts render in the OS shell on the top-level origin, never inside a sandbox iframe, where a sandbox could draw a fake button. An agent can prepare a checkout or a public write. Completing one takes a human gesture in trusted chrome, unless the user granted an explicit budget or standing permission.

Public writes need this. The firehose and archives keep them, so a stray cp must not be able to post to Bluesky.

D11. Apps are views over files #

nibling ships a suite of apps similar to Google Workspace. Each app is a sandbox. Files map to apps by lexicon NSID instead of MIME type. Build order follows the building block each app forces:

App Lexicon Forces
Drive the namespace record the store and namespace split, sharing as a link move
Docs site.standard.document plus a content lexicon live CRDT mounts, presence, comments
Sites site.standard.publication the publish capability, public trees
Forms the form is a lexicon write validation, delegate-DID writes into a Space
Sheets a grid CRDT; a collection directory is already a table formulas as reactive JS in a sandbox
Calendar possibly community.lexicon.calendar.event (unverified) runners, for reminders with no tab open
Mail comail.at adapter API-native providers, the credential broker
Domains at.marque.* record-native providers, trusted chrome
Live place.stream.* provider media playback
Meet none yet the relay and a WebRTC media server on a runner

Drive comes first, then Docs.

D12. Every OS app runs headless as well as with a GUI #

Each app has a headless core and a GUI. The core is an ESM module whose commands operate on the app's files. It is a capability like any other (D7), so it also generates its .d.ts and nib CLI commands. The GUI is one client of the core. An agent, a script, or a runner uses the same core with no rendering. nib:browser stays for GUIs that have no core, such as third-party pages.

Precedents: VS Code commands, macOS App Intents, and Emacs, where every UI action is also a callable command.

Consequences:

  • Each app ships two entry points from one package: the core, and the GUI that imports it.
  • The core's commands are the unit of permission. A GUI gets no permissions its core lacks.
  • Tests drive the core directly. GUI tests cover only rendering and input.
  • Runners can run apps headless, for example a Calendar reminder that fires with no tab open (D6).

D13. nibling's workloads are web-shaped #

nibling follows StackBlitz, not CodeSandbox. CodeSandbox runs arbitrary Linux in Firecracker microVMs. StackBlitz runs web development in the browser, and its agent product (Bolt) builds web apps. Everything nibling plans is web-shaped already: JS and TypeScript, Node and npm, web frameworks, atproto records, and the apps in D11.

Consequences:

  • Python, data science, and native toolchains are non-goals. A runner does not absorb them. Pyodide may cover some Python use, and nibling makes no promise beyond that.
  • ChromeOS is the precedent for a web-shaped OS meeting non-web work. Its browser is the shell, and Linux apps run in a separate VM (Crostini) that reaches the user's files only through explicitly shared folders. nibling's runners play that role across a placement boundary instead of a VM boundary.
  • Runners stay JS isolates (D6), which keeps server costs far below a microVM fleet.
  • Tool fidelity for agents is measured against web-shaped tasks: an agent-weighted task corpus, scored by how many tasks finish in the browser and how many need a runner.

D14. Firehose triggers run headless app cores #

An automation is an app's headless core (D12) whose trigger is atproto events instead of a person. Its manifest declares the trigger, and its code exports a handler:

// manifest: { trigger: { jetstream: { collections: ["app.bsky.feed.post"], dids: ["did:plc:..."] } },
//             nib: ["at"], publish: { budget: "20/day" } }
export default {
  async onRecord(event, { at }) { /* match, then write a record through nib:at */ },
};

The same handler runs in two places. In the browser, it runs against a recorded or live slice of Jetstream, for writing and debugging. On a runner, it runs continuously. Code mode fits: an agent writes the handler, and a low-code builder in the style of airglow.run generates one.

The runner side needs hosted infrastructure:

  • A Jetstream consumer that filters by collection and DID before dispatch, because the firehose volume is high.
  • A dispatcher that hands each event to an isolate. A Durable Object per automation fits: one writer, its own state, and its own cursor.
  • Delivery at least once, resumed from Jetstream's cursor. Handlers must be idempotent; deterministic record keys make record writes idempotent.
  • The runner's credential broker, holding OAuth tokens whose scopes come from the manifest (D9).
  • Per-automation rate limits and the publish budget (D10), because automated posting is spam by default.

Automations stay event-driven and short-lived. They are not a path back to general-purpose server compute.

D15. nibling hosts your PDS's apps: Vercel on your PDS #

The hosting product is "Vercel on your PDS". A user's code, content, domains, and mail are already separate providers tied to one DID: tangled for code, standard.site for content, marque.at for domains, comail.at for mail. nibling is the runtime that composes them, and its paid service is serving and compute.

Vercel feature nibling version Hosted infrastructure
Git-connected deploys A push to a tangled repo triggers a build. Builds are web-shaped (D13), so the browser kernel can run them; it already runs Vite. Only when a runner builds
Preview deployments A link that runs the sandbox in the viewer's browser None
Static hosting The build output is blobs plus a site-manifest record that maps paths to blob CIDs, owned by the user's DID. A gateway serves it. The gateway and its CDN cache
Functions and webhooks D14 handlers and webhooks in runner isolates (D6) Runners
Domains at.marque.dns records point a domain at the gateway None
Mail comail.at, brokered by the kernel (D9) None

A deploy is a record the user owns, and any gateway can serve it. Moving to another host means changing a DNS record, while sites, history, and domains stay with the DID. nibling charges for serving and compute, not for holding the user's data.

Tangled keeps a repo's records on the PDS (org.tangled.repo.manifest), but the git data lives on a knot. Builds clone from the knot directly, which knots allow from browsers (see Open questions).

Build order, each step useful alone:

  1. Previews, which cost nothing to serve.
  2. Static deploys: the site-manifest record and a gateway.
  3. marque.at domains.
  4. Functions and webhooks on runners.
  5. nibling hosts Spaces (#atproto_space_host). This gives write hooks on private work, compute next to the data, and private or preview-only deploys.
  6. Optional full PDS hosting, for users who want repo, Spaces, and compute in one account.

Hosting Spaces comes before hosting PDSes. A PDS host takes on identity custody (signing keys, account recovery, email), uptime, takedown and abuse duties, and federation limits on new hosts. A Space host gets most of the benefit without them. One rule holds for every step: runners, hooks, and deploys must work for users whose PDS nibling does not host. Hosting is a convenience, never a requirement.

D16. Live files sync through Loro: a doc per file and a tree per space #

Source files (D5) sync live through Loro (loro-crdt). Each file is its own Loro doc, keyed by the file's stable identity. Each space's namespace is one more Loro doc whose LoroTree maps names to those identities. A sandbox loads only the docs it mounts (D4), and each space syncs on its own. One doc per tree would break both, because part of a doc cannot be synced.

A bakeoff on 2026-10-30 compared Loro 1.17.1, Yjs 13.6.33, Yjs 14.0.0-rc.7 (@y/y) and Automerge 3.5.0. It ran on an Apple M1 with the 259,778-edit automerge-paper trace from josephg/editing-traces, and every library reproduced the trace's final text. Medians of 5 runs:

Loro Yjs 13 Yjs 14 RC Automerge
Apply the full trace 573 ms 2.35 s 15.8 s 29.2 s
Load the full-trace snapshot 0.8 ms 22 ms 41 ms 862 ms
Snapshot size (full / shallow) 251 / 65 KB 311 KB 377 KB 129 KB
Cold start in a Web Worker (Chromium) 19 ms 7 ms 9 ms 290 ms
Transfer size, brotli (JS + wasm) 852 KB 20 KB 26 KB 802 KB
Concurrent move cycle stays a tree yes no no no
Plain Worker in workerd yes yes yes yes
Dynamic Worker, globalOutbound: null yes yes yes no
CodeMirror 6 binding round-trips yes yes no yes

Loro wins on the two things nibling cannot work around. Its tree has a real move operation and keeps the namespace acyclic when two people move folders into each other. Yjs and Automerge have no move, so a parent-pointer map ends in a cycle cut off from root, and nibling would have to write and maintain the repair. Loro is also the fastest to apply and to open. Its cost is about 840 KB of brotli wasm and 12 ms more worker start-up than Yjs. Under D4 the wasm is a pinned, content-addressed mount, so a user downloads it once.

Rejected:

  • Automerge: about 50 times slower than Loro on the full trace, a 290 ms cold start, and its wasm fails to start in a Dynamic Worker with internal error; reference = …. The cause was not investigated.
  • Yjs 14 RC: 7 times slower than Yjs 13 on the full trace, and y-codemirror.next throws Not same Y.Doc against it. Its API is a new generic YType with deltas, not Y.Text or Y.Map.
  • Yjs 13 is the fallback if bundle size ever matters more than tree moves.

Consequences:

  • A write is applied against the version its writer read. A whole-file write such as fs.writeFile from an agent is diffed into edits. If the diff is taken against the current text, it deletes edits that arrived after the writer read the file. The bakeoff showed this in all four libraries. So the kernel records, per process and file, the Loro frontiers the process last read. A write forks the doc at those frontiers (forkAt), applies LoroText.update(newText), and merges the result. Concurrent typing then survives. A write from a process that never read the file replaces the current text. This is part of the D5 journal format.
  • Names are not unique in the CRDT. Two people who rename different files to the same name in one directory get two tree nodes with that name. The filesystem layer shows both under deterministic distinct names.
  • A record's body stays the plain lexicon value, so any atproto app can read it. Loro state is stored beside the record, not in it. Where it is stored is an open question.
  • Opening a file loads a shallow snapshot (65 KB for the full trace, against 251 KB with history), and history loads on demand.
  • The browser uses loro-crdt/web with its wasm as a separate file. A plain Worker uses loro-crdt/bundler, which wrangler bundles as a wasm-module import. A Dynamic Worker receives the wasm as a WebAssembly.Module in the loader's module map. workerd cannot compile wasm from bytes at run time, so these are the only paths that work there.
  • Editors bind through loro-codemirror. Its default text lookup uses the key "codemirror", and the editor must mount empty and let the plugin sync the initial text. Otherwise it desyncs silently, then crashes. nibling's editor wrapper enforces both. A ProseMirror binding (loro-prosemirror) for Docs has not been tested yet.
  • The relay does not need to understand Loro. It forwards updates by doc identity, so the relay transport question is unaffected.

The probe code and full results are in probes/crdt/ (correctness/RESULTS.md and perf/RESULTS.md).

D17. The app shell is a layout tree of panels, and every change is a command #

The app shell is the OS shell on the top-level origin (D10). It arranges panels the way VS Code does: an explorer on the left, editors in the middle, a terminal below them, and an agent conversation on the right. Each panel is a view over a file, a process, or a conversation (D11). The shell owns where panels are. It does not own what they show.

The layout is one tree. A split holds children along an axis with fractional sizes, and a stack holds tabbed panels with one active. A panel is { id, kind, input }, and its input names what it views:

Kind Input Identity Home slot Renders on
explorer root the root primary chrome
editor path the path main a sandbox (the app that edits the file)
preview path the path main a sandbox (guest HTTP)
terminal cwd, session the session panel chrome (it draws bytes, never guest DOM)
agent conversation the conversation secondary chrome (it draws a transcript file)

Opening a panel whose identity is already open activates the existing one, so open a.md twice gives one tab. Apps add kinds by augmenting the PanelInputs type.

VS Code's parts (side bars, editor area, panel) are tags on stacks, not separate regions. A stack can carry a slot: main, primary, secondary, or panel. A kind opens into its home slot. Toggling a slot hides its stack and keeps its size. Closing a slot's last panel removes the stack, and the next open recreates it on that slot's edge, with the panel spanning the whole editor area. Because every part is the same kind of node, any panel can go anywhere, and one move algorithm covers tabs, splits, and parts. VS Code has two layout systems, and it had to add "terminal in the editor area" as a separate feature.

Considered and rejected: fixed parts with an editor grid inside, as VS Code builds them. Toggling is simpler in that model, but it splits the layout into two systems.

Every change is a Command (open, close, activate, move, resize, toggle, focus) applied by a pure reducer, so the shell has a headless core (D12). Named shell commands such as layout.toggle.panel and panel.split.right wrap commands for keybindings, a palette, and a nib shell CLI. The playground's terminal has an open command to show that a shell drives the same commands as the GUI.

The layout belongs to one viewer and is not shared. Two collaborators in one space each arrange their own panels, so the layout is plain JSON, not a CRDT. What collaborators share is the files the panels view (D16). Who has which file open is presence and goes through the relay. Stored layouts are parsed at the boundary. A layout that fails a check is discarded for the default, and panels that no stack shows are dropped.

The DOM renderer rebuilds the tree of splits, tab bars, and gutters only when the structure changes. Focus and the active tab are repainted in place, so a pointerdown that focuses a stack keeps the element under the pointer. Panel bodies are never reparented. Each body is created once and positioned in one layer over its stack's content area, because moving an iframe in the DOM reloads it. Element.moveBefore() preserves iframe state in Chromium 133 and Firefox 144, but not in Safari. A Playwright test moves a preview between stacks and checks that its document survives.

Code: packages/app/src/shell/ (layout.ts is the model and reducer, workbench.ts the store and the storage parser, commands.ts the named commands, dom/ the renderer, stock views, and structural CSS). Run pnpm --filter @nibling/app dev for the playground.

Still stubbed:

  • The stock views read an in-memory Files interface. The kernel filesystem and Loro docs (D16) replace it. The editor is a textarea until the CodeMirror and Loro binding lands.
  • The terminal is a line shell with ls, cat, open, and preview. bashkit from examples/slopbox replaces it.
  • The agent panel appends messages to .agents/<id>.json and calls no model (D8).
  • Sandbox-origin panels render in-page. The preview uses an opaque-origin srcdoc iframe as a stand-in for a sandbox origin (D3).
  • Clicks inside an iframe do not move focus to its stack, because iframes swallow pointer events. Window blur with document.activeElement can detect them.
  • There is no command palette UI, no arrow-key navigation between tabs, and no panel maximize yet.

D18. A project is a record on its author's PDS #

nibling is a StackBlitz or CodePen whose projects live in the author's own repo. A project is one run.nibling.project record (packages/lexicons/lexicons/run/nibling/project.json). It lists paths, and each path points to a blob holding that file's bytes. Its other fields are an optional git base, how to preview it, the project it was forked from, tags, and a thumbnail.

run.nibling.project/3m2...
  name: "hello vite"
  files: [{ path: "index.html", content: <blob bafk...> }, { path: "src/main.ts", content: <blob ...> }]
  base: { repo: did:plc:<tangled repo>, ref: "main", commit: "9f1c..." }
  preview: { kind: "command", command: "npm run dev", install: true }
  forkOf: { uri: at://did:plc:<other>/run.nibling.project/3m1..., cid: ... }
  • A save uploads only blobs whose CID changed, then writes the record with swapRecord set to the CID the editor loaded. A swap failure means someone else saved. The client reloads, merges, and retries (D16 applies the merge once files are Loro docs).
  • With a base, the file list is an overlay on a git commit. Entries replace base files, and #removed entries delete them. An imported repo with 4,000 files stores only what the user changed, so the record stays small and blob uploads stay few.
  • Without a base, the list is the whole tree. The lexicon caps it at 1,000 entries. Larger projects belong in a tangled repo.
  • Blobs are capped at 5 MB per file. Dependencies are never saved. The sandbox installs them from the lockfile (D4).
  • Forks write forkOf, so "forks of X" is a backlink query (D21).
  • Projects are public until Spaces ships (D5). Until then an unsaved project lives in the browser, and the save button says that saving publishes.

A project's address is /<handle>/<slug>, and an account's is /<handle>. No page name can collide with an account: a handle always has a . and a DID a :, and page names have neither. A DID works in place of the handle, and an address spelled with a leading @ moves to the bare one. The slug is an optional record field, not the record key. The key stays a TID, so a rename never moves the URI that likes, forks, and comments point at. The SDK makes the slug from the name on create and appends -2, -3 while another of the author's projects has it. Renaming to a taken slug fails. A slug uses lowercase a-z, 0-9, and single dashes, and is never shaped like a TID, so a URL segment that parses as a TID is read as a record key first. The rkey address keeps working and moves to the slug address in place. The same name takes a suffix for each surface: /edit opens the editor, /preview shows only the project in the opaque-origin sandbox, and /embed is the iframe view. /new is the editor on unsaved files, and its first save gives the project its address.

This is the first concrete store of D5. A project directory mounts at /at/<did>/run.nibling.project/<rkey>/, and the namespace links it to ~/projects/<name>.

Considered and rejected: one record per file. Saving a project would be many writes that cannot share a swapRecord, so two saves could interleave. Inlining file text in the record: small pens would read nicely in a repo browser, but the record size limit is not confirmed, and blobs keep every file the same shape.

Not yet verified: the PDS's maximum record size, the blob upload rate limit, and how soon a PDS garbage-collects blobs that no record references.

D19. Sign-in is atproto OAuth in the browser, with scopes for our collections #

nibling.run is a public OAuth client. @atcute/oauth-browser-client runs PKCE and DPoP in the page, and client-metadata.json is a static file at https://nibling.run/oauth/client-metadata.json. Development uses the http://localhost loopback client.

The client asks for granular scopes, not transition:generic:

atproto
repo:run.nibling.project repo:run.nibling.feed.like repo:run.nibling.feed.comment
repo:run.nibling.graph.follow repo:run.nibling.actor.profile
blob:*/*
rpc:org.tangled.git.receivePack?aud=*

The last scope lets the PDS mint the service-auth token that a tangled push needs (D20). Creating tangled repos adds repo:org.tangled.repo.declaration and rpc:org.tangled.repo.createRepo?aud=* when that flow lands.

A public client's session lasts two weeks at most, and a confidential client's refresh token lasts up to 180 days. Signing in every two weeks is acceptable for a start. Longer sessions need no token proxy. The client stays in the browser, and the API worker (D22) only signs client assertions (atproto proposal 0010), which @atcute/oauth-browser-client requests through its fetchClientAssertion hook. Tokens never pass through our servers.

We use atcute (@atcute/client, @atcute/lexicons, @atcute/lex-cli) rather than @atproto/api. atcute is smaller and is built for the browser. @atcute/tangled still ships the old sh.tangled.* lexicons, so the few org.tangled.* types we use live in packages/lexicons.

Open: whether rpc:...?aud=* is enough for com.atproto.server.getServiceAuth with a knot audience, and whether we publish a permission set (include:run.nibling.auth...). Publishing the lexicons (packages/lexicons/scripts/publish.ts) needs one TXT record per NSID authority, which is the NSID without its last segment: _lexicon.nibling.run for run.nibling.project, plus _lexicon.actor.nibling.run, _lexicon.feed.nibling.run, and _lexicon.graph.nibling.run.

D20. Tangled is the git integration point #

nibling does not integrate with GitHub. Git import and push go to tangled knots, straight from the browser.

A tangled repo is its own atproto identity. The owner's PDS holds org.tangled.repo.declaration/<name> with { repo: <repo DID> }. The repo DID's document names its knot with a #tangled_knot service entry, the way an account's document names its PDS.

  • Import resolves <handle>/<name> to the repo DID, then the DID to the knot. It fetches one commit with isomorphic-git over smart HTTP, through the gateway's /git/<repo DID> (D22), or straight from the knot, which allows any origin, when the gateway is down or not configured.
  • The declaration already names the repo, so import writes nothing. The editor opens the repo as it is at /tangled.org/<owner>/<name>, which is a tangled URL with https:// replaced by nibling's origin. A whole tangled URL pasted after the origin goes to the same place. Opening a repo needs no sign-in. Saving it creates a project in the saver's repo, with the opened commit as its base. The repo's owner can push from the opened repo without saving it. The owner segment is a handle or DID, never a slug, so tangled.org's own /tangled.org/<slug> projects keep their addresses. A project address that finds no project, but names one of the actor's repos, offers to run that repo instead.
  • Push asks the user's PDS for a service-auth token with audience set to the knot and lxm set to org.tangled.git.receivePack. Tangled's docs say that "HTTPS push takes a service-auth token bound to org.tangled.git.receivePack as the Bearer token or Basic password." isomorphic-git sends it as the password. No SSH key and no runner are involved. The token expires within 60 seconds, so the client mints one per push.
  • Clones skip tangled.org, whose redirect to the knot has no CORS headers (Open questions).
  • Tangled is moving from sh.tangled.* to org.tangled.*, and accounts hold both today. Reads try org.tangled.* first and fall back to sh.tangled.*.

Probed 2026-11-06, read-only, against knot1.tangled.sh (v1.20.0):

  • A repo DID's document lists the knot twice, as #tangled_knot (type TangledKnot) and as #atproto_pds, because the knot also serves the repo DID's own records.
  • org.tangled.knot.describeKnot reports did:web:knot1.tangled.sh, which is the audience a push token names.
  • git-receive-pack and its info/refs preflights return 204 with Access-Control-Allow-Origin: *. An unauthenticated push gets a 401 with CORS headers and WWW-Authenticate: Basic realm="knot". The Basic username is ignored, and the password is parsed as the JWT.
  • Both clone URL forms (/<repo DID> and /<owner DID>/<name>) answer info/refs.
  • importRepo fetched tangled.org/knot-docker (22 files) in Node. A push against a local git http-backend with the same Basic-password scheme passed. Legacy sh.tangled.repo records come in two shapes, keyed by name or by TID with a name field, and some lack repoDid.

Not yet verified: a real push to a knot, whether the OAuth rpc: scope is enough for getServiceAuth with a knot audience, whether a cross-origin 401 with WWW-Authenticate: Basic makes any browser show a credentials prompt, and any of this in a browser. isomorphic-git 1.44 also needs a global Buffer in the browser.

Tangled's Bobbin is the model for our index (D22). It is a read-only AppView that keeps everything in memory and rebuilds from the firehose when it starts, so it is never the source of truth.

D21. Social features ride on public indexes #

nibling has CodePen's social layer: pages, follows, likes, comments, forks, and a feed. The records are ours, in the shapes Bluesky already uses (run.nibling.feed.like, run.nibling.feed.comment, run.nibling.graph.follow, run.nibling.actor.profile). Our own collections keep backlink queries specific to nibling, and they keep nibling's meaning out of Bluesky's graph.

The browser reads most of the social layer from microcosm, with no index of ours:

Question Source
Likes, forks, comments on a project Constellation backlinks: run.nibling.feed.like:subject.uri, run.nibling.project:forkOf.uri, run.nibling.feed.comment:subject.uri
Followers of an account Constellation: run.nibling.graph.follow:subject
A record by URI, an identity Slingshot (getRecordByUri, resolveMiniDoc), then the PDS itself
An account's projects listRecords on its PDS

The browser reaches them through the gateway (D22), and directly when the gateway is down. Constellation and Slingshot send Access-Control-Allow-Origin: *. Both are optional. With them down, a page falls back to the PDS and shows no counts.

Importing follows reads the user's app.bsky.graph.follow, org.tangled.graph.follow, and sh.tangled.graph.follow records. It keeps the accounts that use nibling (run.nibling.actor.getKnown, D22) and writes run.nibling.graph.follow records with via set to the source collection, at most 200 per applyWrites. Copying all of them would write thousands of records and run into the PDS's write limits for accounts nobody can see on nibling yet.

Tangled orgs are ordinary accounts with their own DID. An org page is the same page as a person's: its run.nibling.actor.profile, its projects, and its tangled repos from org.tangled.repo.declaration. An org member who is signed in as the org can pin projects. Open: tangled has no public record for org membership, so the page cannot list members yet.

D22. Our infrastructure is one stateless worker #

Everything that runs on our servers is in packages/api, a Cloudflare Worker. The app works without it, with no global feed and no link cards.

  • The read path. Every read the app makes goes through the worker's gateway: records, listings, and blobs from each PDS, identity, Constellation backlinks, Slingshot's record cache, and git fetches from knots. The gateway caches each answer in the Cache API. The Cache API is per data center and its purges stay local, so every entry ages out on a TTL instead. Blobs, records pinned by CID, and git fetches of named commits keep for a year. Current records and listings keep for 15 seconds, then serve stale for a minute while they refresh. Identity keeps for 5 minutes, and backlinks for 15 seconds. A read of the signed-in account's own repo sends x-nib-fresh: 1 and skips the cache, so a save shows on the next read.

  • A User-Agent. Every request we originate says nibling.run/<version> (+https://nibling.run/contact), so a service can reach us about runaway traffic. A browser cannot set that header, which is one more reason reads go through the worker. The SDK sends it from Node, and the worker cannot send a request without it.

  • Direct reads are the fallback. When the gateway gives no answer, times out, or returns a 5xx, the SDK retries that read once against the service itself. With no gateway configured, every read is direct, as before.

  • Writes and pushes stay direct. A PDS write carries an OAuth DPoP token bound to a key in the browser, and a push carries a service-auth token that is single-use and lasts 60 seconds. Neither can pass through a proxy, so the gateway refuses git-receive-pack, and tokens still never reach our servers.

  • The index. A Durable Object consumes Jetstream filtered to run.nibling.* and keeps recent projects and like and fork counts. It serves run.nibling.feed.getRecent, run.nibling.feed.getTrending, and run.nibling.actor.getKnown. As with Bobbin, it holds a cache and is never the source of truth. When its cursor falls outside Jetstream's replay window, it rebuilds from com.atproto.sync.listReposByCollection on a relay and listRecords on each repo.

  • Link cards and embeds. Social crawlers do not run JavaScript, so /<handle>/<slug> and /<handle>/<slug>/embed return HTML with Open Graph tags and an oEmbed link, read through Slingshot, then hand off to the app.

  • OAuth client metadata, and later the client-assertion endpoint that makes nibling a confidential client (D19). The worker holds the signing key and never sees a user's tokens.

Not on our servers as a source of truth: project files (the PDS), git (knots), previews (the viewer's browser), backlinks (microcosm), identity (PLC and DNS). The gateway only caches reads of them. Runners (D6) come later and stay optional.

D23. Package metadata comes from any gateway, and from none #

An install without a lockfile walks the dependency graph one level at a time, with a registry request for each package: 765 requests and about 10 seconds for astro. A gateway that walks the graph server-side and streams every packument as NDJSON (run.nibling.npm.getClosure) cuts that to a few requests. The resolver starts on the first lines while the gateway still walks deeper levels. In a browser, a cold astro install, tarballs included, went from 21.7 s to 6.4 s against a self-hosted gateway.

Gateways follow tangled's knots. The query is a published lexicon, and @nibling/npm-gateway is software anyone runs (Node, Deno, Bun, or a Worker), as anyone runs a knot. A DID names a gateway with a #nibling_npm service entry of type NiblingNpmGateway, and a self-hosted one can serve its own did:web document. nibling.run's worker serves the query and did:web:api.nibling.run as one gateway among any.

A client chooses its gateways when it is built, not per project (user, 2026-11-06). A build names them as a repo names its knot: by DID or hostname (NIB_NPM_GATEWAYS). At boot, the client resolves each name to its #nibling_npm endpoint (@nibling/npm-gateway/resolve), so an operator can move a gateway without a client release, and passes the endpoints to boot({ npm: { gateways } }). The kernel tries them in order, with 5 seconds for each stream's first line and 5 seconds of silence after that. It has no default, and an install never needs one. A page's CSP must allow the DID documents' origins and the endpoints (gatewaySources()), so an endpoint that moves to a new origin needs the policy recomputed. Until then, the page installs without that gateway.

  • The client picks every version. A packument in a closure is cut to the versions the walk's specs accept and lists the specs it is complete for, and the kernel answers only those specs from it, so its picks match npm's. A gateway cannot choose versions for a client. It can only fail to help.

  • A gateway is trusted the way a registry mirror is, for freshness and nothing more. The kernel ignores a closure from another registry, and any packument whose tarballs are not on the registry or whose version documents name another package or version. Tarballs are checked against their integrity as before. A dishonest gateway can still hide a release, which a client cannot detect without asking npm.

  • The kernel's substitution rules travel in the request (alias=esbuild:esbuild-wasm, binding=wasm32-wasi), so a gateway needs no copy of them and keeps working when they change.

  • The stream is NDJSON. A spec line says that the versions sent so far for a name hold every candidate for one raw spec, so the kernel answers that spec the moment its line arrives. A spec no line names waits for the stream's end, then loads from the registry. A stream without its end line was cut short, and only what arrived is used.

  • The gateway's walk has no levels: it follows each package's dependencies as soon as that package loads, so a slow packument holds back only its own subtree. While its upstream is slow, it sends a wait line every 2 seconds, so the kernel can tell a slow registry, which it waits for, from a gateway that is gone, which it gives up on after 5 quiet seconds. A walk loads at most 900 upstream documents, under a Worker's subrequest limit.

  • Measured 2026-11-06, Node, cold self-hosted gateway, medians of 3: express resolves in 0.83 s, against 1.20 s with a single JSON response; vite in 1.06 s against 1.41 s; astro in 2.43 s against 3.95 s. With a warm gateway they are within noise of each other. With astro's packument stalled for 7 seconds, a stream with heartbeats still resolved from the closure in 9.4 s, where one without them fell back to 766 direct requests and took 15.8 s.

Inspiration: vlt's client asks a registry for /-/vlt/capabilities and POST /-/vlt/resolve, which streams the resolved closure as NDJSON and fails soft to per-package requests. As of 2026-11-06 we found no server implementation of it in vsr or elsewhere. The difference is who picks: in vlt's protocol the server resolves, and in ours the client does.

Where the code stands #

Area State Gap
ESM Native module loading over the Service Worker virtual origin Default process runtime is Node (D2)
Kernel Single filesystem owner behind a typed syscall table Objects have no stable identity (D5); no permissions (D3)
Persistence In-memory store Sync format before OPFS (D5), built on Loro (D16)
Resolver One scheme check for node: Add nib:
Previews Guest HTTP through the Service Worker and kernel port table Per-sandbox origins (D3), nib:browser (D7)
Shell bashkit in examples/slopbox Same permission checks as code mode (D7)
App shell <nib-shell> over the layout tree and commands in packages/app/src/shell (D17); a compact touch layout with a coding key bar; installable PWA playground Views bound to the kernel and Loro; sandbox-origin panels
Packages web has design tokens and WAI-ARIA elements (tree, split, tabs, toolbar, menu); docs and www are empty
Lexicons @nibling/lexicons: run.nibling.* records and queries (D18, D21, D22), vendored org.tangled.*, types generated by @atcute/lex-cli Not published (_lexicon.nibling.run)
Platform SDK @nibling/sdk: identity through Slingshot, OAuth with granular scopes (D19), the project store (D18), tangled import and HTTPS push (D20), the social layer on Constellation (D21), the index client. Tested against an in-memory PDS and a local git http-backend Nothing has run against a real PDS write or a real knot push
Projects in the app Sign-in with several accounts and switching between them, new, open, save, fork, tangled import and push as shell commands over a headless Platform core (packages/app/src/platform). One header on every page runs them; / is the home page (your projects, then the feed), /new the editor on unsaved files, /<handle>/<slug>/edit the editor on a project, /tangled.org/<owner>/<name> the editor on a tangled repo as it is, /settings the accounts. Playwright specs run the flows against fakes Views still read Files, not the kernel; save conflicts are reported, not merged
API worker @nibling/api: the caching read gateway with a User-Agent on every request, Jetstream v2 index in a Durable Object with relay backfill, feed and getKnown queries, link cards and oEmbed (D22) Never deployed; client-assertion endpoint not built

Open questions #

  • Sandbox origins: which registrable domain hosts them? (CSP on Service Worker responses does apply to workers; see D3.)
  • Spaces: what is the encryption model, and how does a record move between a public repo and a Space?
  • Links whose record a collaborator deleted: dangling, tombstoned, or pruned?
  • Where is a record's Loro state stored: a blob the record references, the space host, or both? (D16 chose the CRDT.)
  • How should the filesystem name two nodes that end up with the same name in one directory (D16)?
  • Does loro-prosemirror work for Docs?
  • Where does a viewer's shell layout live: a private record per space, one per device, or both (D17)?
  • How does an app declare its panel kinds: in its sandbox manifest, beside the lexicons it opens (D11, D17)?
  • Relay transport: a WebSocket per space, or WebRTC with signaling?
  • Runner protocol and discovery: a DID service entry, a workspace record field, or both?
  • npm gateways (D23): should a request say what the client already holds (vlt's have)? Should we also answer vlt's protocol, so vlt clients can use our gateways?
  • Runner isolation: Deno processes, V8 isolates (workerd), or both? How are a runner's isolates limited for CPU and memory?
  • How much do tab throttling and discarding affect a long-running sandbox in a background tab? (Not yet measured.)
  • Which content lexicon for Markdown documents?
  • jj in wasm (probed 2026-10-30 on jj-lib 0.46.0, commit 633b5fcd1e56): unmodified jj-lib fails on wasm32-wasip1 because file_util.rs has no WASI platform module. With open upstream PR jj-vcs/jj#9849 applied (a WASI platform module and a lockfile-based FileLock), cargo check -p jj-lib --no-default-features --target wasm32-wasip2 passes. wasm32-unknown-unknown then fails only on getrandom needing its wasm_js feature; the fork PR manno23/jj-api#2 reports a clean build and passing tests with that fix, which we did not reproduce. The open question is the git backend. --no-default-features drops gitoxide, and without it jj cannot read or write the git repos that knots serve. Nobody has tried gitoxide on WASI. jj-lib's storage also calls std::fs directly, so it needs a WASI layer over our kernel's VFS rather than a pluggable store.
  • Knot CORS (probed 2026-10-30, answered for reads): knot1.tangled.sh sends Access-Control-Allow-Origin: * on info/refs and git-upload-pack, and its preflight returns 204. A browser can clone directly from a knot. It cannot clone through tangled.org, which answers with a 307 to the knot that carries no CORS headers, and answers OPTIONS with 405. So the client must resolve the knot URL itself; which record names it is not yet confirmed. The allowed request headers are Authorization, Content-Type, atproto-proxy, so a browser cannot send Git-Protocol and is limited to protocol v1. A git-receive-pack preflight also passes, but whether a knot accepts HTTPS push, and with what credential, is unknown.
  • For agents, is snapdom's JSON context output or an accessibility-tree snapshot the better default?
  • Does marque apply DNS edits made directly to the PDS record?
  • What do atmosphere.money's checkout API and lexicons look like?
  • Does comail have lexicons or plans for OAuth? Does Streamplace support many-to-many calls?
  • How do atproto OAuth permission scopes (repo:, rpc:, blob:) work for third-party clients?
  • PDS blob limits for static hosting: maximum blob size, total storage, and blob count per account. Large sites may need the gateway to cache from a knot instead.
  • Does the Spaces alpha let a space host receive write events directly, as commit hooks?
  • Gateway abuse handling: free static hosting on custom domains attracts phishing. What reputation and review does the gateway need from the first release?
  • Bluesky's relay limits on accounts per new PDS host, if nibling ever hosts PDSes.

Next steps #

  1. Accept or amend these decisions.
  2. Probe gitoxide on WASI, since jj in the browser depends on it. (The CSP, knot CORS, and jj-lib probes ran on 2026-10-30; see D3 and the open questions.)
  3. Done 2026-10-30 (see D3): the sandbox-origin probe passed in all three engines. Real Safari 27.0 also passed on its default feature flags. Follow-ups are Service Worker restarts. The server.ts header options are done.
  4. Done 2026-10-30: exec(entry, { runtime: "web" | "node" }), default node (commits 73c2a4b, de60485). Follow-up done the same day: the kernel read the runtime worker's own channel as well as its private port, so guest postMessage could forge runtime messages; it now reads only the port. A guest close() in a web process now exits it with code 0 after the current task, as close() ends a worker. Node globals for npm packages inside a web process, and flipping the default, come later.
  5. Add the nib: scheme with a scoped nib:fs and nib:context, and generate their types.
  6. Design the store and namespace format (D5) on Loro (D16), including writes applied against the version their writer read, then build OPFS on it.
  7. Build Drive on that format.
  8. Platform, in order: sign in and save against a real PDS; push to a real knot (the open items in D20); publish the lexicons; deploy the worker; bind the editor's views to the kernel so a saved project runs; the client-assertion endpoint (D19).