# 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 `` 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///.json public repo; a write is putRecord /at/ -> /at/ handles are symlinks, DIDs are stable /spaces///.json private or collaborators-only (an atproto Space) /src/tangled.org// cloned lazily on first access /skills/ 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//site.standard.document/3lx... ~/drafts/next-post.md -> /spaces//site.standard.document/3ly... ~/projects/zine/ -> /spaces//... ``` 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/` 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: ```js // 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](https://loro.dev) (`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/.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. ## 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 | `` 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); `sdk`, `api`, `docs`, `www` are empty | Everything above tier 1 | ## 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? - 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.