Skip to content

Developer API

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.

interface Extension {
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
}
interface ServiceDef {
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
}
type StopAction =
| { signal: Signal } // send signal, wait for exit
| { bin: string; argv?: string[] } // run a stop command
type ReloadAction =
| { signal: Signal } // convention: SIGHUP
| { bin: string; argv?: string[] } // run a reload command
interface RestartPolicy {
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:

type Preset = (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.

function Unix(): UnixBuilder
interface UnixBuilder {
// 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:

const base = Unix().use(stdSystem())
const withDb = base.use(sqlite()) // base is not modified
const withHttp = 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.

interface UnixImage {
// Data contract for the Runtime
createBootContext(): BootContext
// Create a new builder pre-loaded with this image's layers
extend(): UnixBuilder
}
interface BootOpts {
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.

import { nodeRuntime } from '@fishnet/core/node'
import { browserRuntime } from '@fishnet/core/browser'
import { testRuntime } from '@fishnet/core/test'

The relationship between builder, image, runtime, and instance mirrors Docker:

Dockerfishbowl
DockerfileBuilder chain (.use() calls)
docker build.build()UnixImage
containerd runtime shimnodeRuntime() / browserRuntime() / testRuntime()
docker runruntime.boot(image, opts)UnixInstance
Image layer (readonly)Frozen MemoryFS
Container layer (writable)Fresh MemoryFS overlay
FROM existingimage.extend() → new builder with existing layers
-v /host:/containervolumes in BootOpts

Usage

import { Unix, stdSystem } from '@fishnet/core'
import { nodeRuntime } from '@fishnet/core/node'
// Build image
const image = await Unix()
.use(stdSystem())
.env('EDITOR', 'ed')
.build()
// Boot via runtime
const rt = nodeRuntime()
const sys = 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
const image = await Unix()
.use(stdSystem())
.use(dataTools())
.use(sqlite())
.build()
// Boot many via runtime — shared base, independent writable layers
const rt = nodeRuntime()
const agents = await Promise.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:

const base = Unix()
.use(stdSystem())
.build()
const dataImage = base.extend()
.use(dataTools())
.use(sqlite())
.file('/etc/motd', 'Data science environment.')
.build()
const agent = 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
↓ fallthrough reads
Image layer 2 (frozen — from dataImage) ← dataTools files, sqlite config
↓ fallthrough reads
Image layer 1 (frozen — from base) ← stdSystem dirs, default configs

Reads check the writable layer first, then fall through to each frozen layer in order. Writes always go to the top writable layer (copy-on-write).

OverlayFS

OverlayFS is a fileserver that wraps two other fileservers — same 10-method protocol, transparent to everything above:

function overlayFS(upper: Fileserver, lower: Fileserver): Fileserver {
return {
async open(path, flags) {
if (flags.write || flags.create) {
// Copy-on-write: copy from lower to upper on first write
if (!await exists(upper, path) && await exists(lower, path)) {
await copyToUpper(lower, upper, path)
}
return upper.open(path, flags)
}
// Read: try upper first, fall back to lower
try { return await upper.open(path, flags) }
catch (e) {
if (e.code === 'ENOENT') return lower.open(path, flags)
throw e
}
},
async remove(path) {
// Whiteout: write a sentinel file in the upper layer so the
// deleted name is hidden even though the lower layer still has it.
// Convention: `.wh.<filename>` — same as Docker's overlay2.
await upper.remove(path).catch(() => {})
const dir = dirname(path)
const base = basename(path)
const whiteoutPath = join(dir, `.wh.${base}`)
const fd = await upper.open(whiteoutPath, { create: true, write: true })
await upper.close(fd)
},
async readdir(path) {
// Merge entries from both layers, filtering out whiteouts
const upperEntries = await tryReaddir(upper, path)
const lowerEntries = await tryReaddir(lower, path)
const whiteouts = new Set(
upperEntries
.filter(e => e.name.startsWith('.wh.'))
.map(e => e.name.slice(4))
)
return mergeEntries(
upperEntries.filter(e => !e.name.startsWith('.wh.')),
lowerEntries.filter(e => !whiteouts.has(e.name))
)
},
async stat(path) {
// Check for whiteout before falling through to lower
const dir = dirname(path)
const base = basename(path)
try {
await upper.stat(join(dir, `.wh.${base}`))
throw fsError('ENOENT', path) // whiteout exists — file is deleted
} catch (e) {
if (e.code === 'ENOENT') { /* no whiteout, proceed */ }
else throw e
}
try { return await upper.stat(path) }
catch (e) {
if (e.code === 'ENOENT') return lower.stat(path)
throw e
}
},
async wstat(path, changes) {
// Copy-on-write: if node only exists in lower, copy to upper first
if (!await exists(upper, path) && await exists(lower, path)) {
await copyToUpper(lower, upper, path)
}
return upper.wstat(path, changes)
},
// ... remaining methods (read, write, close, mkdir, rename)
// follow the same upper-then-lower pattern
}
}

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)
2. For each frozen mount in the image:
- Create OverlayFS(upper: fresh memoryFS(), lower: frozen layer)
- Mount the overlay at the same path
3. Share stateless/immutable references:
- 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.

const sharedWorkspace = memoryFS()
const persistentHome = indexedDbFS(db)
const agent1 = await image.boot({
tty: tty1,
volumes: {
'/shared': sharedWorkspace, // shared between instances
'/home': persistentHome, // persistent across reboots
},
})
const agent2 = await image.boot({
tty: tty2,
volumes: {
'/shared': sharedWorkspace, // same reference — read/write shared
'/home': indexedDbFS(db2), // different persistent store per agent
},
})
Image mountVolume
When mounted.build() time.boot() time
OverlayYes — frozen base + writable copy-on-writeNo — direct access
In snapshotYes — writable layer capturedNo — external to instance
Shared between instancesBase layers shared (read-only)Fully shared (read-write)
LifecycleTied to image/instanceTied 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.

Snapshot Types

TypeWhat’s capturedProcesses on restore
ColdFilesystem + packages + envStart fresh
WarmCold + process table + service stateRe-spawned with same argv/env/cwd
interface UnixInstance {
// Control
spawn(bin: string | BinFunction, argv?: string[], opts?: SpawnOpts): Promise<ChildHandle>
wait(): Promise<number>
shutdown(): Promise<void>
restart(): Promise<void>
// State
snapshot(): Promise<Snapshot>
suspend(): Promise<void>
resume(): Promise<void>
// Inspection
kernel: Kernel
}

What a Snapshot Captures

interface Snapshot {
// 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[]
}
interface ProcessRecord {
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:

interface Fileserver {
// ... 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.

interface UnixBuilder {
// ... existing methods ...
restore(snap: Snapshot): UnixBuilder // apply snapshot state
}
// Restore as a new instance
const sys = await image.extend()
.restore(previousSession)
.build()
.boot({ tty })
// Or create a new derived image with the snapshot baked in
const customImage = image.extend()
.restore(agentWorkspace)
.build()
// Boot multiple agents from the customized image
const agents = await Promise.all(
ttys.map(tty => customImage.boot({ tty }))
)
// Restore + extend — additional .use() calls layer on top of restored state
const enhanced = 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.

Instance Lifecycle

Unix() ← configure
.use(stdSystem())
.use(packages...)
.build() ← freeze into image
├── image.boot({ tty }) ← instantiate (many times, shared base)
│ │
│ ├── running...
│ ├── sys.snapshot() ← capture writable layer
│ ├── sys.suspend() ← pause all processes (warm)
│ ├── sys.resume() ← unpause
│ ├── sys.restart() ← shutdown + reboot (same image)
│ └── sys.shutdown() ← stop everything
├── image.boot({ tty, volumes }) ← another instance, shared base + volumes
└── image.extend() ← derive a new image
.restore(snap)
.use(moreStuff)
.build() ← new image with additional layers

stdSystem Preset

The standard system preset provides the default Unix environment:

function stdSystem(): Extension {
return {
mounts: {
'/': memoryFS(),
'/tmp': memoryFS(),
'/dev': devFS(),
'/proc': procFS(),
'/pkg': pkgFS(),
},
bins: { cat, grep, sed, awk, ls, cp, mv, rm, mkdir, /* ...all core bins */ },
env: {
PATH: '/bin:/usr/local/bin',
HOME: '/home',
PWD: '/',
SHELL: '/bin/sh',
TERM: 'fishbowl',
PKG_REGISTRY: 'https://registry.js-unix.dev',
},
}
}

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.

PID 1: init (generated)
├── httpd (from @fishnet/http-server, restart: true)
├── dbsyncd (from @fishnet/sqlite, restart: false)
└── /bin/sh (user shell session)

Init behavior:

init:
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:

import { Unix, stdSystem } from '@fishnet/core'
import { nodeRuntime } from '@fishnet/core/node'
import { testRuntime } from '@fishnet/core/test'
// Build image (platform-agnostic)
const image = await Unix()
.use(stdSystem())
.use(dataTools())
.build()
// Boot via platform-specific runtime
const rt = nodeRuntime()
const instance = await rt.boot(image, { tty, volumes: { '/shared': sharedFS } })
// Multi-agent with shared image
const agents = await Promise.all(
agentTtys.map(tty => rt.boot(image, { tty }))
)
// Tests — zero config
const testInstance = await testRuntime().boot(image)

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
const bytes = await image.serialize() // → Uint8Array or Blob
const image = await UnixImage.deserialize(bytes) // → UnixImage

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:

Expressfishbowl 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:

stdin/stdout → CLI usage (local)
HTTP req/res → REST endpoint (network)
WebSocket → streaming RPC (bidirectional)
MCP messages → tool calls (LLM integration)

A thin adapter would bridge the transport:

// REST: request body → stdin, query → argv, headers → env
// stdout → response body, exit code → HTTP status
app.post('/api/grep', (req, res) => {
spawn(grep, ['--pattern', req.query.pattern], {
stdin: req.body, stdout: res,
env: { AUTH_USER: req.headers.authorization },
})
})
// MCP: tool arguments → argv, tool input → stdin
// stdout → tool result, exit code → success/failure
mcpServer.tool('grep', async (args) => {
const result = await run(grep, [args.pattern], { stdin: args.input })
return { content: result.stdout }
})

Middleware Composition (TBD)

The connect next() pattern could provide bin-level middleware for cross-cutting concerns that pipes can’t express (auth, logging, resource limits, retries):

type BinMiddleware = (proc: ProcContext, next: () => Promise<ExitCode>) => Promise<ExitCode>
const withAuth: BinMiddleware = async (proc, next) => {
if (!proc.env.AUTH_TOKEN) return exitCode(1)
return next()
}
const withLogging: BinMiddleware = async (proc, next) => {
const start = Date.now()
const code = await next()
await proc.stderr.write(`[${proc.argv[0]}] ${Date.now() - start}ms\n`)
return code
}
const secureGrep = compose(withAuth, withLogging, grep)

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.