The public interface for configuring, composing, and booting a fishbowl system. Replaces the Unix({ fs, bins, env }) config bag with a composable builder pattern.
Extension
The atomic unit of composition. Everything — presets, packages, .use() arguments — is an Extension.
interfaceExtension {
mounts?:Record<string, Fileserver>
bins?:Record<string, BinFunction>
env?:Record<string, string>
files?:Record<string, string> // seed files written after mounts
services?:ServiceDef[] // long-running processes started by init
}
interfaceServiceDef {
name:string
bin:string
argv?:string[]
env?:Record<string, string>
// Lifecycle
stop?:StopAction
reload?:ReloadAction
restart?:RestartPolicy
// Dependencies
requires?:string[] // must be running — fail if they can't start
wants?:string[] // should be running — don't fail if they can't
after?:string[] // ordering only — start after these
before?:string[] // ordering only — start before these
}
typeStopAction=
| { signal:Signal } // send signal, wait for exit
| { bin:string; argv?:string[] } // run a stop command
typeReloadAction=
| { signal:Signal } // convention: SIGHUP
| { bin:string; argv?:string[] } // run a reload command
interfaceRestartPolicy {
policy:'always'|'on-failure'|'never'
maxRetries?:number
backoff?:'linear'|'exponential'
delay?:number// base delay in ms, default: 1000
gracePeriod?:number// ms before SIGKILL after stop, default: 5000
stableAfter?:number// ms of uptime to reset retry counter, default: 60000
}
This is the canonical ServiceDef shape — the init spec’s ServiceUnit adds kind: 'service' for the dependency graph, but Extension uses this flattened form. The builder wraps each ServiceDef into a ServiceUnit at build time.
An Extension is a bag of things to add to a system. Mounts, bins, env vars, seed files, and service declarations. No behavior, no callbacks, no lifecycle — just data.
A preset is a function that returns an Extension, optionally accepting configuration:
typePreset= (config?:unknown) =>Extension
Merge Semantics
When multiple Extensions are composed via .use(), they merge in order:
mounts: later wins per path. .use(a).use(b) where both mount /home → b’s fileserver wins.
bins: later wins per name. Two Extensions providing grep → the later one’s implementation wins.
env: later wins per key.
files: collected in order, all written. If two Extensions write to the same path, later wins (last write).
services: collected in order, all started. No deduplication — two Extensions declaring the same service name is a configuration error caught at boot.
Decision: later wins, no deep merge. Simple, predictable, matches how CSS cascading and Object.assign work. The developer controls precedence by controlling .use() order. No magic merge strategies, no conflict resolution callbacks.
Decision: bins is sugar — the builder writes exec nodes to memoryFS. The builder collects all bins from all .use() calls and writes them as exec nodes into the root memoryFS at /bin/<name> — file nodes with an exec field carrying the function reference and mode: 0o755. These are real files visible to ls /bin, which, type, and all filesystem operations. See the executables spec for the full exec model — PATH resolution, the exec field on file nodes, and the relationship between native functions and script executables.
UnixBuilder
The builder accumulates Extensions and produces images or bootable instances.
functionUnix():UnixBuilder
interfaceUnixBuilder {
// Primary composition method
use(ext:Extension):UnixBuilder
// Convenience shortcuts (sugar over use())
mount(path:string, server:Fileserver):UnixBuilder
bin(name:string, fn:BinFunction):UnixBuilder
env(key:string, value:string):UnixBuilder
file(path:string, content:string):UnixBuilder
service(def:ServiceDef):UnixBuilder
// Snapshot restoration
restore(snap:Snapshot):UnixBuilder
// Build — freeze into a reusable image
build():UnixImage
// Boot — shortcut: build + boot in one step (for single-instance use)
boot(opts?:BootOpts):Promise<UnixInstance>
}
Design Decisions
Decision: builder is immutable — each method returns a new builder..use(), .mount(), .bin(), .env(), .file(), and .service() all return a new builder instance, leaving the original unchanged. This makes forking safe:
constbase=Unix().use(stdSystem())
constwithDb= base.use(sqlite()) // base is not modified
constwithHttp= base.use(httpServer()) // independent fork from base
The image/instance model implies forking patterns — shared base configurations branching into specialized variants. Mutation would make this error-prone and surprising.
Decision: convenience methods are pure sugar..mount(p, fs) is .use({ mounts: { [p]: fs } }). .service(def) is .use({ services: [def] }). No separate code path, no special semantics. The builder has one real method (use), the rest are ergonomics.
Decision: stdSystem() replaces the old config bag. The default mounts, bins, and env become a preset function. Users who want the “just works” experience call .use(stdSystem()). Users who want control compose their own. No hidden defaults — what you .use() is what you get.
Images
An image is a frozen, immutable system configuration. Multiple instances can boot from the same image via a Runtime, sharing base filesystem layers efficiently through copy-on-write.
interfaceUnixImage {
// Data contract for the Runtime
createBootContext():BootContext
// Create a new builder pre-loaded with this image's layers
extend():UnixBuilder
}
interfaceBootOpts {
tty?:TtyConfig
cwd?:string
volumes?:Record<string, Fileserver> // direct mounts, bypass overlay layers
}
Runtime
The Runtime sits between Image and Instance. It knows what platform it’s on and provides platform-appropriate capabilities (asset loading, ESM evaluation, HTTP fetch, WASM compilation). See runtime architecture for full details.
The relationship between builder, image, runtime, and instance mirrors Docker:
Docker
fishbowl
Dockerfile
Builder chain (.use() calls)
docker build
.build() → UnixImage
containerd runtime shim
nodeRuntime() / browserRuntime() / testRuntime()
docker run
runtime.boot(image, opts) → UnixInstance
Image layer (readonly)
Frozen MemoryFS
Container layer (writable)
Fresh MemoryFS overlay
FROM existing
image.extend() → new builder with existing layers
-v /host:/container
volumes in BootOpts
Usage
import { Unix, stdSystem } from'@fishnet/core'
import { nodeRuntime } from'@fishnet/core/node'
// Build image
constimage=awaitUnix()
.use(stdSystem())
.env('EDITOR', 'ed')
.build()
// Boot via runtime
constrt=nodeRuntime()
constsys=await rt.boot(image, { tty })
Multi-Instance Usage
For spinning up multiple agents from the same base — the primary use case for images:
// Build once
constimage=awaitUnix()
.use(stdSystem())
.use(dataTools())
.use(sqlite())
.build()
// Boot many via runtime — shared base, independent writable layers
constrt=nodeRuntime()
constagents=awaitPromise.all(
agentTtys.map(tty=> rt.boot(image, { tty }))
)
10 agents share one copy of the frozen base filesystem. Each pays only for the files it creates or modifies.
Derived Images
image.extend() returns a new builder pre-loaded with the image’s frozen layers. Additional .use() calls add on top:
constbase=Unix()
.use(stdSystem())
.build()
constdataImage= base.extend()
.use(dataTools())
.use(sqlite())
.file('/etc/motd', 'Data science environment.')
.build()
constagent=await dataImage.boot({ tty })
This is FROM base in a Dockerfile. Each .build() freezes the current state as a new immutable layer.
Layers and OverlayFS
Images achieve efficient sharing through filesystem layering. Each .build() freezes the accumulated MemoryFS state into an immutable layer. When an instance boots, it gets a fresh writable layer on top via OverlayFS.
How Layers Stack
Instance writable layer (empty MemoryFS) ← writes go here
Decision: OverlayFS is a fileserver, not a kernel feature. It implements the same 10-method protocol as every other fileserver. Nothing in the kernel, shell, or bins changes. The layering is invisible to everything above the mount point. This validates the protocol design — a fundamental capability like copy-on-write layering is just another fileserver.
Decision: whiteouts use .wh.<filename> sentinel files, not a custom method. Deletion in the upper layer creates a zero-byte .wh.<name> marker via standard open/close. readdir filters out .wh.* entries and hides any lower-layer name that has a corresponding whiteout. stat and open check for whiteouts before falling through. This follows Docker’s overlay2 convention and requires no extensions to the Fileserver protocol.
What .build() Does
build():
1. Merge all accumulated Extensions (same merge semantics as before)
2. Create and populate filesystems
3. Write seed files from `files` entries
4. Freeze all MemoryFS instances (mark immutable, reject future writes)
5. Return UnixImage holding:
- Frozen filesystem layers (including exec nodes at /bin/*)
- Merged env defaults
- Service declarations
- Package manifests
What image.boot() Does
image.boot(opts):
1. Create fresh kernel (new process table, pid counter)
- Exec nodes in frozen base layers (native function references on file nodes — shared, immutable after build)
- DevFS (stateless)
4. Create per-instance fileservers:
- ProcFS (points at this kernel's process table)
- TtyFS (from opts.tty)
5. Mount volumes directly (no overlay):
- For each entry in opts.volumes → mount at path
6. Determine PID 1 (init or shell, based on service declarations)
7. Return UnixInstance
Memory Efficiency
For the multi-agent browser use case:
Without layers (10 agents):
10 × full MemoryFS copy = 10 × ~500KB = ~5MB
With layers (10 agents):
1 × frozen base = ~500KB (shared)
10 × empty writable overlay = ~0KB each (grows only on write)
Total: ~500KB + negligible per-agent overhead
Exec nodes in the frozen base layers are naturally shared — they’re file nodes with function references in immutable MemoryFS layers. DevFS is stateless. The only per-instance cost is the writable overlay (initially empty) + TtyFS + ProcFS.
Volumes
A volume is a fileserver mount that bypasses the image layer system. It’s mounted directly at boot time — no overlay, no freezing, no snapshotting.
constsharedWorkspace=memoryFS()
constpersistentHome=indexedDbFS(db)
constagent1=await image.boot({
tty: tty1,
volumes: {
'/shared': sharedWorkspace, // shared between instances
'/home': persistentHome, // persistent across reboots
},
})
constagent2=await image.boot({
tty: tty2,
volumes: {
'/shared': sharedWorkspace, // same reference — read/write shared
'/home': indexedDbFS(db2), // different persistent store per agent
},
})
Image mount
Volume
When mounted
.build() time
.boot() time
Overlay
Yes — frozen base + writable copy-on-write
No — direct access
In snapshot
Yes — writable layer captured
No — external to instance
Shared between instances
Base layers shared (read-only)
Fully shared (read-write)
Lifecycle
Tied to image/instance
Tied to caller
Use cases for volumes:
Shared state between agents (same MemoryFS reference)
Persistent storage that survives instance shutdown (IndexedDB, S3, real fs)
External data that shouldn’t be part of the image (databases, API mounts)
Decision: volumes are just fileserver mounts at boot time. No new concept — it’s the same mount operation the kernel already supports. The distinction between “image mount” and “volume” is when it’s mounted and whether it goes through the overlay. The volumes field in BootOpts makes this explicit.
Snapshots
A snapshot captures an instance’s mutable state — the writable filesystem layers, installed packages, environment, and process/service records.
// Filesystem — only mutable layers (writable overlays)
fs:Record<string, SerializedFS>
// Package state — what was installed at runtime
packages:PackageManifest[]
// Environment — current shell env
env:Record<string, string>
// Warm snapshot additions (optional)
processes?:ProcessRecord[]
services?:ServiceState[]
}
interfaceProcessRecord {
bin:string
argv:string[]
env:Record<string, string>
cwd:string
}
What is NOT captured:
Frozen image layers — they’re already in the image, no need to duplicate
Volumes — external, managed by the caller
Synthetic fileservers (ProcFS, DevFS) — regenerated from live state
Running execution state (closure + promise chain) — not serializable in JS
Fileserver Opt-In
Each fileserver declares whether it participates in snapshots:
interfaceFileserver {
// ... existing 10 methods ...
snapshot?():Promise<SerializedFS> // opt-in: serialize my state
restore?(data:SerializedFS):Promise<void> // opt-in: load from serialized
}
MemoryFS implements both. ProcFS, DevFS don’t. Persistent fileservers (IndexedDB, S3) don’t need to — they’re already persistent. The exec fields on file nodes are not serialized in snapshots — native function references are not serializable, and the frozen image layers already contain the exec nodes. Runtime-added bins are tracked via package manifests.
Restoring from a Snapshot
A snapshot is restored via .restore() on the builder. A Snapshot is not an Extension — its fs field contains Record<string, SerializedFS> (serialized filesystem state), not Record<string, string> (seed file contents). The builder handles this by deserializing filesystem state back into writable MemoryFS layers, re-registering packages, and converting warm process records into service declarations.
interfaceUnixBuilder {
// ... existing methods ...
restore(snap:Snapshot):UnixBuilder// apply snapshot state
}
// Restore as a new instance
constsys=await image.extend()
.restore(previousSession)
.build()
.boot({ tty })
// Or create a new derived image with the snapshot baked in
constcustomImage= image.extend()
.restore(agentWorkspace)
.build()
// Boot multiple agents from the customized image
constagents=awaitPromise.all(
ttys.map(tty=> customImage.boot({ tty }))
)
// Restore + extend — additional .use() calls layer on top of restored state
constenhanced= image.extend()
.restore(previousSession)
.use(additionalTools())
.build()
What .restore() does internally:
restore(snap):
1. Deserialize each SerializedFS → writable MemoryFS
2. Queue these as overlay layers (applied at build time)
3. Merge snap.env into accumulated env
4. Record snap.packages for re-registration at boot
5. If warm: convert snap.processes → ServiceDef[] for init
6. Return new builder with accumulated state
Decision: .restore() is a builder method, not a free function returning Extension. A Snapshot contains SerializedFS data that requires deserialization — it cannot be expressed as plain Extension fields. Making it a builder method keeps the type system honest: files: Record<string, string> stays as seed file content, and serialized filesystem state gets its own restoration path. The builder knows how to deserialize; Extension doesn’t need to.
This is a regular function returning a regular Extension. Nothing privileged about it. A user can write their own mySystem() that mounts different fileservers, provides different bins, sets different env vars. The builder doesn’t know the difference.
Init Generation
When any Extension declares services, the builder generates an init bin from the accumulated service list. Init is not a user-facing concept — it’s an implementation detail of “some packages need background processes.” See the init spec for the full service management design.
1. Start units in dependency order (see init spec)
2. Start shell
3. Wait for any child to exit:
- Service with restart policy → consult policy, respawn or mark failed
- Shell → begin shutdown
4. Shutdown: stop services in reverse dependency order
5. Exit with shell's exit code
Decision: shell exit triggers shutdown. The shell is the session owner. Services are supporting infrastructure. When the LLM agent’s shell exits, the system shuts down. Services alone don’t keep the system alive. This matches the primary use case: the agent’s shell is the interface, everything else serves it.
Decision: no services → no init. If no Extension declares services, PID 1 is the shell directly. Zero overhead for the common case. Init only exists when it’s needed.
Decision: init is generated, not configured. The builder constructs the init bin from service declarations. There’s no /etc/init.conf to edit, no init system to learn. The Extension’s services field is the declaration. The builder translates it.
What This Replaces
The legacy Unix({ fs, bins, env }) constructor and image.boot() have been replaced by the builder + runtime model:
image.boot() no longer exists. Boot always goes through an explicit Runtime. The Runtime provides platform capabilities (asset loading, ESM evaluation, HTTP fetch, WASM compilation) that the boot procedure needs.
Image Serialization (Future)
Status: post-v1. Image serialization is a future extension point, not part of the initial design.
Images are currently in-memory only — they exist for the lifetime of the host process. A natural extension is serializing an image to a transferable format:
// Future API sketch
constbytes=await image.serialize() // → Uint8Array or Blob
This would enable: saving images to IndexedDB for browser persistence, transferring images between workers/tabs, distributing pre-built images as artifacts. The frozen-layer architecture makes this feasible — each layer is already an immutable MemoryFS that could implement snapshot()/restore().
What Doesn’t Change
Everything below the API surface — kernel, fileservers, shell, bins, pipes, signals, the entire architecture — stays exactly as specified. This is a new front door, not a renovation. The builder’s job ends at build/boot. Once the system is running, it’s the same kernel, same processes, same fileserver protocol. OverlayFS is a fileserver like any other — the kernel doesn’t know about layers.
TBD: Bins as Protocol Handlers
Status: TBD. This section records a design observation, not a committed decision. The mapping is suggestive but needs concrete design work before it becomes part of the spec.
A bin’s interface — (readable input, writable output, metadata) → status — maps structurally to a connect/Express-style handler:
Express
fishbowl bin
req (readable + headers)
proc.stdin + proc.env + proc.argv
res (writable + status)
proc.stdout + return exitCode(n)
next()
| (pipe to next stage)
app.use(mw)
Pipeline composition
This suggests that bins are already protocol-agnostic handlers. The same bin could be fronted by different transports without modification:
The connect next() pattern could provide bin-level middleware for cross-cutting concerns that pipes can’t express (auth, logging, resource limits, retries):
Key difference from pipes: middleware is in-process (shared context, can mutate env/metadata), pipes are inter-process (isolated, byte-stream only). These compose orthogonally — a middleware-wrapped bin can still participate in pipelines.
Open Questions
Should BinFunction itself become middleware-shaped, or should middleware be a separate composable type with adapters?
How would builder-level global middleware work? .middleware(withLogging()) wrapping all bins adds power but could surprise users.
Does the RPC/adapter story belong in the core API or as a separate package (e.g., @fishnet/http-adapter, @fishnet/mcp-adapter)?
The mapping breaks where Express middleware can mutate req (add req.user) but Unix pipeline stages can only transform the byte stream. Env vars bridge this for in-process middleware but not for pipe-connected stages.