Skip to content
Concepts

Concepts

Run your own Apps on Cloudflare Workers or celld: installable App Releases, each with its own Durable Object, SQLite database and declared bindings

Oyster puts a desktop of Apps on your own Cloudflare account: a git platform, agents, a terminal, an editor, workflows and image generation, each built from a git commit on your own Computer.

The Tourtois desktop on Oyster with three Apps tiled: Trellis editing the Bonsai checks graph, Bonsai drawing a repository's network of agent forks, and Shed's terminal on the Computer

Deploy one Worker, run npx oyster install-apps, and your Oyster builds seven Apps on its own Computer and opens on the Tourtois desktop. A live Oyster runs at https://oyster.shiit.app/, and CONTEST.md is the five-minute tour of it.

oyster install-apps installs these Apps. Each one lives in its own repository under github.com/fungi-computer, and each installed App is also a template your agents can fork and rebuild on the same Computer.

  • Tourtois is the desktop: a launcher, tiled or floating windows, menus and settings.
  • Bonsai hosts git repositories on Cloudflare Artifacts. Each agent pushes to its own fork with ordinary git, and the network graph shows the forks folding back into main.
  • Grove holds conversations with agents that call every installed App as code.
  • Shed opens a terminal on the Computer, with its ports and processes.
  • Inkcap edits the Computer’s files.
  • Trellis runs typed graphs of App methods on Cloudflare Workflows, started by a push, a webhook or a schedule.
  • Prints generates images and video with Workers AI.

Stage is not published or included in the 0.3 starter set. It appears as a glowing ember swarm and directs animated scenes and cards as an agent answers.

Bonsai's koi-pond repository: a network graph with one lane per agent fork, its three open Changes and the live activity feed
Bonsai: a repository three agents build together
Grove: the conversation list beside a finished conversation in which the agent ran three scripts against Bonsai and listed the repositories with open changes
Grove: an agent answers by calling Bonsai
Trellis editor with the Bonsai checks graph: Workflow input, a Change script node and a Bonsai Checks.report step, between the Palette and the Inspector
Trellis: a graph of App methods
Inkcap with the koi-pond workspace in the explorer and src/fish.ts open beside README.md
Inkcap: the Computer's files
The Tourtois desktop: Grove's inbox of agent conversations and a launcher for Bonsai, Grove, Inkcap, Prints, Shed and Trellis
Tourtois: the inbox and the launcher
Prints: a gallery of five generated images, two koi ponds, a knight dunking a basketball, an elf holding a basketball and a sunrise over water, above the prompt bar
Prints: images from Workers AI

Shed is in the desktop picture at the top. Oyster itself is the host underneath: git through Cloudflare Artifacts, a Computer (a Linux workspace in a Cloudflare Container) that builds Apps and runs their terminals and files, Agents on Workers AI, Workflows and media.

Fungi runs AI Agents on their own Computers, inside Teams that share Agents, Computers, Apps and a bill. Oyster is its App host, released on its own, and it is built from 21 more Fungi packages released with it. Each one is MIT-licensed, has its own repository at github.com/fungi-computer/<name>, installs from npm as @fungi.computer/<name>, and works without the rest. Installing Oyster brings in the ones it runs on.

Apps and hosting:

  • Agar: the App contract. Typed backend methods, host capabilities and the connection between an App’s frontend and its host.
  • Puffball: a catalog of web Apps and their immutable Releases, which serves only promoted Releases and checks every artifact against its recorded digest.
  • Gills: compact plain-text App views shared by browser and terminal hosts.
  • Whistle: one command definition becomes key bindings, a command palette, slash commands, help, a menu and an agent tool, all running through one typed execute door.

Agents and tools:

  • Shiitake: durable Agent conversations. A Session holds accepted work, messages and observations; the host supplies storage, models and tools.
  • Mule: runs Pi’s model and tool loop inside Effect as an awaited, cancellable run.
  • Mycelium: gives an Agent typed operations through one execute tool, with a capability snapshot acquired and released per run.
  • Knapsack: connects an Agent to Computers, files and remote MCP servers as Mycelium capability sources.
  • Forage: lists, searches and describes a catalog of operations, so a caller or a model finds the right one.
  • BirdDog: durable mail between agent sessions, with replies retried from an outbox until they are accepted.

Durable work, git and keys:

  • Watchdog: a small durable job engine on SQLite, D1 or PostgreSQL. Each job settles with exactly one recorded outcome, even across restarts.
  • Carrier: durable email delivery whose receipts record only what the provider said, and ordered envelopes between Durable Objects.
  • Burrow: the repository owner for Cloudflare Artifacts, serving only Git’s smart-HTTP operations and never returning a credential.
  • Ear Tag: bearer keys and their hashes, a typed grant vocabulary, and a tenant-scoped key store with revocation, expiry and a per-key rate limit.
  • Cairn: typed, opaque identifiers, temporary keys and candidate word names, with their parsers.

Interface:

  • Stipe: a CSS-first theme layer for Tailwind CSS v4 and daisyUI 5, with spacing, fonts and material tokens.
  • Caps: accessible React primitives and blocks, from buttons and dialogs to a chat composer and window frames, themed by Stipe.
  • Maosaico: arranges React panes inside a measured container, with floating frames and optional tiling.
  • Sigils: Fungi’s decorative SVG marks and their gallery.
  • Sprite: shows an animated character through a renderer the host installs.
  • Sprite Zdog: a Canvas renderer for Zdog characters, with built-in characters and authoring tools for Sprite.

Paste this prompt into a coding agent (Claude Code, Codex or any other) to have it set up a local Oyster, install an App, connect over MCP and deploy:

TEXT
Set up Oyster for me. Install it from npm as @fungi.computer/oyster and follow
https://github.com/fungi-computer/oyster/blob/main/docs/site/agent-quickstart.mdx
step by step. Run each command exactly as written, compare its output with the
expected output, and use the "If it fails" notes before improvising. Stop and
ask me before step 7 (deploy).

The guide ships in the package as docs/site/agent-quickstart.mdx. It needs Node 24, pnpm and Git; a Cloudflare account matters only for the deploy step.

Apps are Releases with provenance. packRelease builds an App from one commit and records it as sourceRevision: git:<sha>. Every artifact is addressed by its SHA-256 digest, and Oyster verifies each digest before it installs or replaces a Release. Replacing a Release keeps the App’s data; deleting it purges the data and leaves a tombstone.

Every App is isolated. An App’s backend loads through a Worker Loader into its own Durable Object facet with its own SQLite database. It has no ambient network access. It receives only the bindings its Release declares (GIT, EVENTS, Computer, agent, workflow, media), and Oyster checks that declaration again on every request. App pages are served with a deny-by-default Content Security Policy inside a sandboxed frame.

Git is a binding. An App that declares git.repository gets env.GIT: create, fork, log, read and merge on Cloudflare Artifacts, scoped to its installation. People and agents push with ordinary git through Oyster’s git door using their own keys; the host keeps every provider credential. Each accepted push reaches the App’s onGitPush handler and can start a Workflow or a signed webhook.

Agents are first-class callers. POST /mcp serves one MCP endpoint for the whole Oyster. Every installed App’s methods appear as typed env.<APP> modules that an agent discovers and calls from code; a dotted method is a nested property, e.g. env.BONSAI.repos.list({}). Writes need explicit approval. Each agent has its own key, so every push and call is attributed.

Workflows compose Apps. Trellis stores each graph in git, so a commit is a version. Each graph compiles to a plan with a digest and runs on Cloudflare Workflows. Steps call App methods and can run a sandboxed Code node or a step in a Computer.

Computers are ordinary capabilities. A Computer is a durable workspace with a Container backing. Shed opens its terminal, Inkcap edits its files and port previews serve what it runs on signed hostnames. Apps reach it only through the computer.* capability they declare.

The desktop is a trusted shell. Tourtois is installed like any other Release, but your Oyster renders its home installation as the top-level page, with every permission you have as owner. Each App it opens still runs in its own sandboxed frame inside that App’s window. Pick a shell you trust: it can do anything you can.

An Oyster is one Worker. Every App installed on it is an immutable Release built from an exact git commit. Its UI runs in a sandboxed frame, its backend runs in its own Durable Object with its own SQLite, and it receives only the bindings it declares. Agents reach every installed App through one MCP endpoint. The same Worker runs on Cloudflare Workers and on celld, a Workers-compatible runtime you can host yourself.

Oyster architecture: one Worker routes browsers, git clients and MCP agents to a registry, per-App Durable Objects, the git door and the Tourtois home, over R2, Artifacts and platform bindings

Piece Owns
Router Sign-in, /apps/<installation>/ pages, App methods, owner /api/* routes and /mcp.
OysterRegistry The installation list: which Release each installation runs, and its visibility.
R2 Release artifacts, stored by digest.
App Instance One Durable Object per installation: lifecycle, owner state and the backend facet.
Backend facet The App’s own code and SQLite database, loaded through the Worker Loader.
git door Smart-HTTP clone, fetch and push, admitted per key and forwarded to Artifacts.
Tourtois The trusted shell: launcher, windows, menus and settings, rendered as the top-level page.
Platform bindings Workflows, Computers, Agents and Workers AI, offered only to Apps that declare them.

The diagram source is docs/media/architecture.mmd.

@fungi.computer/oyster/standalone is the whole host the quick start deploys: the git root below plus tenancy, a Computer (ComputerDO, a Cloudflare Container or an on-box VM), Agents on Workers AI composed from stock Shiitake Durable Objects, Workflows, media, and the owner routes that seed App templates and build them on the Computer. examples/standalone/wrangler.jsonc declares every binding it uses. Set the owner key as a secret (OYSTER_OWNER_KEY, 24+ characters) and deploy with wrangler deploy. Sign in at /login.

Hosts may supply the optional OYSTER_KEY_SESSIONS binding, implementing OysterKeySessionResolver from @fungi.computer/oyster/auth. Its resolve(value) returns { keyId } for a live host-owned session, or null. The host mints and expires opaque sessions and sets the keySessionCookieName cookie; it is not a named key’s bearer secret. Hosts should use Path=/, HttpOnly, Secure and SameSite=Lax (or stricter) as defense in depth. Oyster independently checks request provenance. Cookie-only GET/HEAD navigation is allowed. Other methods require Origin to be present and equal the request URL’s origin, and refuse Sec-Fetch-Site: cross-site. Missing Origin is refused, using the same strict predicate as the channel’s POST routes. Oyster stores no browser sessions. keySessionAuth wraps ordinary ownerOrKeyAuth at the standalone tenancy composition root. Each cookie admission reopens the live key and checks its canonical owner; every authenticated door still uses that key’s current grants. App pages refuse keys without App access, even when the App is public to visitors. Explicit header authentication is unchanged, and a failed Authorization header cannot fall back to a named-key cookie. The owner cookie and owner-only management API are unchanged; without the binding named-key cookies admit nobody.

The celld example is a limited trial, not the full Cloudflare setup above. It requires access to Fungi’s private celld fork and Botanical source repository. Use examples/standalone/wrangler.celld.jsonc beside worker.ts, not the Cloudflare-only config. The published 0.5.0 tarball does not yet contain this celld example; copy it from your authorized Botanical source checkout until a package including it is released.

The layers it is built from stay exported for hosts that compose their own: @fungi.computer/oyster/turnstile owns Cloudflare challenge configuration and token verification. Hosts supply their origin, verification timeout and safe error reporter; they retain their own challenged routes and HTTP responses. Configuration keeps the explicit TURNSTILE=off opt-out and fails closed on HTTPS hosts without both keys. Hub retains its five-second verification policy.

Other host building blocks: @fungi.computer/oyster/workers (Releases, Instances, MCP and App storage) and @fungi.computer/oyster/workers-git (the git door), with oysterAppMethods supplying the installed App catalog to an Agent runtime.

Pack and install an App with @fungi.computer/oyster/pack: packRelease turns an exact Git commit and a canonical build recipe into a Release with immutable artifacts, and installRelease uploads it. Each installed App gets:

  • its UI at /apps/<installation>/, in a sandboxed frame connected through Agar’s app.backend capability;
  • its methods at POST /apps/<installation>/methods/<name> (JSON in and out);
  • its own SQLite state in its Instance’s app facet.

createOyster(host) from @fungi.computer/oyster/host is the same server over your own adapters (auth, artifacts, installations, instances, mcp, capabilities).

Git is one optional field of the typed host: host.git. When it is present, createOyster mounts the git door at /git/<installation>/<repo>.git, and people and agents clone, fetch and push with ordinary git. Nothing else in the host changes.

There are two ways to get it:

  • Use one Git root. oysterGitRoot(options) from @fungi.computer/oyster/workers-git is the whole composition: the git door plus tenancy, Agents and their owner-only approval routes, workspace and capability slots, the page script and the home App. Its hostFor(env, scope) is the only way a tenant host is built. Pass the root to createOysterGitWorker for HTTP and to createOysterGitMcpOwner for MCP, and return root.hostFor(this.env, scope) from your OysterGitInstance subclass, so HTTP, MCP and background wakes share one composition. tenancy is required; a single-owner root passes ownerOrKeyTenancy, so the owner key and named keys sign git in. @fungi.computer/oyster/standalone is a real multi-door caller. Its wrangler.jsonc adds the ARTIFACTS binding and the KEYS and WEBHOOKS Durable Objects.
  • Set host.git on your own host. Keep your own createOyster(host) composition and set git: oysterWorkerGit(env, { scope }) and ownerRoutes: gitAdminRoutes(env, scope) for /api/keys and /api/webhooks, with the tenant scope your host selected.

The git root, from Workers:

worker.ts
import type { TenantScope } from "@fungi.computer/oyster/tenant";
import {
createOysterGitMcpOwner,
createOysterGitWorker,
OysterGitInstance,
oysterGitRoot,
ownerOrKeyTenancy,
type OysterGitWorkerEnv,
} from "@fungi.computer/oyster/workers-git";
const root = oysterGitRoot<OysterGitWorkerEnv>({ tenancy: ownerOrKeyTenancy });
export class OysterMcp extends createOysterGitMcpOwner(root) {}
export class OysterInstance extends OysterGitInstance {
protected hostFor(scope: TenantScope) {
return root.hostFor(this.env, scope);
}
}
export default createOysterGitWorker(root);

A host also exports the Durable Objects and the git OysterBinding entrypoint, whose table also serves env.GIT and env.EVENTS. The package’s test:git-door check runs that root (test/git-root.fixture.ts) on workerd against real bare repositories: the owner pushes with git, a named agent key’s push is refused by the App’s policy with Git’s own ng report, both keys clone, and the accepted push reaches the App’s onGitPush.

Each layer owns one decision:

MERMAID
flowchart LR
client["git client<br/>clone · fetch · push"]
auth["Authentication<br/>owner key · named key<br/>Workflow Git pass header"]
admission["Git admission<br/>enabled installation · git.repository<br/>tenant scope · Workflow branch grant"]
policy["App push policy<br/>authorizeGitPush, every ref"]
burrow["Burrow<br/>Artifacts provider and repository<br/>host-held credentials"]
events["App events<br/>journal → onGitPush → env.EVENTS<br/>webhooks · Workflow triggers"]
client --> auth --> admission
admission -- "push: ask first" --> policy
admission -- "admitted bytes" --> burrow
burrow -- "accepted refs" --> events
  • Authentication is the host’s own auth. The owner key is a Bearer token; a named key may also be the Basic password from a git credential helper. A Workflow Git pass (a wgp. Bearer header, never in a URL) goes only to host.git.workflowPass; without that port it admits nothing. Anonymous requests read only public installations and never push.
  • Git admission reopens the live installation before every provider call: it must be enabled and its Release must declare git.repository. The tenant scope prefixes every repository name (<installation>--<repo> for the default tenant), so an installation reaches only its own repositories. A Workflow writer may push only the branches its grant names.
  • The App’s push policy answers authorizeGitPush for every ref update before any bytes reach the provider. One refusal rejects the whole push with Git’s native ng report. Unsupported receive-pack commands, including signed push certificates, fail closed.
  • Burrow owns the provider and the repository. The host’s Artifacts adapter mints short-lived credentials through it and forwards the admitted smart-HTTP exchange (up to 64 MiB per request); the App sees repository names, git objects and its own events, never a credential or remote.
  • App events start from the accepted-ref journal. Delivery calls the selected Instance’s onGitPush when the Release declares receives: ["git.push"] and git.repository. The App emits typed events through env.EVENTS: Oyster delivers them as signed Standard Webhooks, and a host with Cloudflare Workflows (the Hub, or the live demo’s root) starts the Workflow triggers armed on that event.

Agent approval stays owner-direct in both compositions: the approval routes are owner routes, refuse App-origin requests, and are never an OysterCapability an App can be granted.

Git bindings, journal and lower-level ports

Section titled “Git bindings, journal and lower-level ports”

A Release’s backend.bindings determines whether its App gets env.GIT (Cloudflare Artifacts, scoped to its installation; credentials stay in the host) or env.EVENTS (delivered as Standard Webhooks to subscriptions the owner creates at /api/webhooks). Each binding enforces its declared operations. await env.GIT.cloneUrl(repo) returns the host-issued clone URL; an undeclared binding grants nothing. Declaration changes invalidate the loader entry even when the backend bytes stay the same. Agents push with keys from /api/keys.

Hosts compose one createAdmittedGit with an installation-scoped store, live host grants, the App’s typed authorizeGitPush policy and an accepted-ref journal. GitWriter, GitPushQuestion, GitPushVerdict and accepted facts come from @fungi.computer/agar/git; the shared wire parser is in agar/git-wire.

An App’s own GIT binding uses admitted.binding(onBehalfOf). Its writer is installation, with the host-attested caller retained in onBehalfOf. These writes skip the App’s external-push policy, retain live host admission and enter the same accepted-ref journal. The journal records only refs accepted by the provider and commits before the exchange is acknowledged. Redelivery keeps the complete original fact and eventId; handlers record that ID with their effects.

createGitPushJournal composes the standalone Instance’s SQLite owner with Carrier delivery and an alarm hook. Its accepted fact, ref tips and Carrier send commit together; Carrier retains delivery attempts across restarts and retries. The Hub composes its ref journal and TeamPost.sendGitPush in one host transaction. Neither HTTP routing nor the provider adapter creates a second push fact.

createGitDoor from @fungi.computer/oyster/git accepts the host’s authenticate, isPublic and git ports. authenticate receives the request and the exact installation, repository and read/write scope it names. The last returns an AdmittedGit; the door forwards reads to .read and pushes to .push. Provider credentials stay inside the storage adapter. createOyster builds these ports from host.auth, host.installations and host.git, so most hosts set host.git and never call createGitDoor directly.

A host whose Workflow run owner issues Workflow Git credentials supplies workflowGitPass in oysterGitRoot or oysterWorkerGit options. The run owner issues one credential per native command of an admitted Git step and gives it only to that command’s Git environment. It travels only as an Authorization: Bearer header starting with Braid’s workflowGitPassPrefix (wgp.) on the ordinary credential-free door URL; it is never part of a URL. That header is admitted only by the port, for one repository and scope, as the arming member under the Workflow’s branch grant, and never as a session or key. It stops working when its command closes or its run ends. Pushes outside those branches are refused before the App’s push policy runs.

Each OysterCapability supplies its Agar id and version, a mount base, relative Hono routes, an admits(principal, installation) decision, and methods. Each method descriptor has kind, method (HTTP method), and path (relative route). CapabilityMethodKind is json | transport | read | write: JSON calls return decoded data, transports return Fetch handles, and reads/writes return Agar’s stream handles.

Method paths may contain named parameters, such as :agentId/sessions. Oyster uses path-to-regexp to compile input fields into those parameters; remaining scalar fields become query parameters for GET and handle calls. Other JSON calls keep their remaining fields in the request body. Transport handles append their Fetch pathname below that resolved route and may declare method: "ANY" to retain the request’s HTTP method. JSON results, including Agar’s { kind: "pending", retryAfterMs }, reach the App unchanged; the page does not poll on the App’s behalf.

For example, a directory method uses { kind: "json", method: "GET", path: "list" }; a file read uses { kind: "read", method: "GET", path: "content" }. fileCapabilities(source) from @fungi.computer/oyster/files supplies the three file contracts over the existing /files/ GET, PUT and POST routes.

The page offers a capability only when the installed Release declares its ID and admits allows the principal. Visitors receive none. Every route request also rechecks that declaration and admission using the X-Oyster-Installation header set by the page; a read grant does not authorize the write route sharing its file mount.

Consent approves capabilities, like OAuth scopes. An App upgrade may add a method that uses an already-approved capability without asking again. A new capability always needs approval. Release selection intersects stored operation and resource approvals; it never adds a grant. Every Worker call receives only the bindings allowed by that method’s declared requires, rather than all the App’s approved capabilities.

The agent.manage consent wording is Can create Agents; write abilities need your approval.

The apps.call permission also shows the declared target Apps and methods. Its route approvals retain those exact pairs: adding or renaming a target method needs owner approval. On Oyster these page calls run as the signed-in member through the target App’s ordinary method gate; Agents, keys and visitors cannot use this path. The target receives its own grants, never the caller App’s grants. The Hub shows the same consent list but does not offer this page capability.

Connect your MCP client to https://<your-oyster>/mcp with Authorization: Bearer <key>. Installed App methods are available through execute as env.<APP>; a dotted method is a nested property, e.g. env.BONSAI.repos.list({}); discover them with forage.search and forage.describe. A key’s or Agent’s App writes run under the owner’s stored approval of its App tools (recorded when the owner creates the key), so a headless client such as claude -p can write. That approval already covers every App on this Oyster, including ones installed after the key existed, with no per-call prompt; anyone holding the key can act as the owner in those Apps, and revoking the key is what stops it. Every other write, including the owner’s own calls, requires a client that supports form elicitation and explicit approval; anonymous callers are refused.

When the host has a Computer, the catalog also holds it as env.computers.workspace, e.g. env.computers.workspace.exec({ command: "ls", cwd: "/workspace" }). Its operations are the ones every Computer advertises (read, list, write, mkdir, bash, exec and the process tools), and each call asks for approval. The host supplies computer(principal) on its workspace: it returns the Computer’s calls for a caller with Computer access and null otherwise. The standalone host gives an owner, key or member the same Computer grant its /computer routes use; visitors and Agents get none. The Computer accepts every call against that grant. oysterComputerTools(host, principal) from @fungi.computer/oyster/mcp returns this Knapsack source and its grants.

Workers route every MCP request to the tenant’s MCP Durable Object. Export OysterMcp from oyster/workers for the default host, or derive it from createOysterGitMcpOwner(root) with the same root passed to createOysterGitWorker(root). Declare the MCP binding and SQLite migration as shown in the standalone example, and configure OYSTER_ORIGIN.

The owner authenticates every request. SDK session IDs, event data and closure belong to that owner; deleting a session releases its catalog connection. If an owner restarts and cannot restore its live SDK transport, the old ID returns 404. Close the client’s old transport and initialize a fresh session. Pending execution and elicitation are never replayed. Node hosts retain their explicit MCP adapter and call its close() when shutting down.

Agent and key adapters use await oysterAppMethods(host, principal, access) from @fungi.computer/oyster/mcp. Pass the result in Mycelium’s sources; its grants names each captured module at the level (read/write) the caller actually holds for it, so Mycelium itself refuses a write call up front when a module’s own grant is read. For an Agent, access is {kind: "agent", policy}, the host’s one stored, blanket {level, revision, grantedBy} approval; missing it grants no tool access. For a named key, access is {kind: "key", keyId, byInstallation}, one {level, revision, grantedBy} per installation the key’s own Ear Tag grants admit (keyAppAccess); an installation the key has no grant for is absent from byInstallation and never listed at all, not merely refused. A key’s grants are set once, at POST /api/keys, and never edited: to change them the owner revokes the key (POST /api/keys/revoke) and issues a new one. A module grant does not approve an App operation. The gate also intersects the installation’s explicit approvals with its verified Release; external methods are refused with approval_required. MCP uses this same projection. Capture again when tool policy, installation authority, a key’s own grants or a Release changes; the source represents one immutable catalog snapshot.

An App with agent.manage can create Agents and request tool-policy changes. The host records the admitted installation; those requests remain pending and have no grantedBy. Only direct owner approval grants App tools. The standalone owner key resolves through the seeded tenancy registry: grantedBy is the canonical stored member ID, while OYSTER_OWNER_NAME is an identity label. The stored approval pins that member’s membershipVersion; demotion and re-promotion, removal and re-addition, or suspension and reactivation require new approval. Old policy rows also require re-approval.

The authenticated owner reads GET /api/agent-policies/:agentId for the current expectedRevision, then sends PUT to the same path with {"level":"write","expectedRevision":"<the returned revision>"} (or null when no policy exists). These host-owned routes reject an App installation header. The Agent must have its host-minted directory/tenant pin; another tenant or an unpinned Agent refuses. A stale revision returns 409, an unknown Agent returns 404, and malformed approval input returns 400.

The owner connects any Streamable HTTP MCP server to one Agent. The Agent’s codemode then sees its tools as env.mcp.<name>.<tool>(), beside the installed Apps; Knapsack’s mcpServers connection owns the client. On a standalone Oyster the Agent’s own Durable Object keeps the endpoint and an optional bearer token, and no route returns the token.

GET /api/agent-mcp-servers lists every Agent with its servers (serverId, name, url, hasBearer). POST /api/agent-mcp-servers/:agentId with {"name":"linear","url":"https://mcp.example.com/mcp","bearer":null} connects one; the name is lowercase letters, digits and _, and the endpoint must be https: with no credentials or fragment. A repeated name or more than 16 servers returns 409. DELETE /api/agent-mcp-servers/:agentId/:serverId disconnects one. These routes are owner-direct and reject an App installation header. A server that is down or refuses the token is left out of that run and the others stay available. OAuth sign-in is not offered yet.

Declare a Cloudflare Browser Rendering binding and every Agent gets a cloudBrowser tool for public pages: open a driven session (optionally at a URL), navigate, read it with snapshot (a bounded accessibility-tree summary), click, type, press, scroll, and close it.

wrangler.jsonc
"browser": { "binding": "BROWSER" }

Every URL the Agent supplies must be http(s), carry no credentials and resolve to a public address; anything else is refused before the browser sees it. A standalone Oyster allows two open sessions at once, closes one after 5 idle minutes or 30 minutes in all, and is billed by Cloudflare to the owner’s account. Without the binding the tool is absent.

Hosts compose the same module, @fungi.computer/oyster/browser, behind one port (BrowserHost): a provider (bindingSessions(env.BROWSER), or browserRunSessions with a Browser Run account token, which also enables the markdown/links/scrape/extract Quick Reads), a required metering owner and an event sink. createBrowser owns lease ownership, the URL policy, reaping and billing order; browserCapabilitySource is the Agent tool. The Agent never reaches raw CDP or runs script in the page. See the API reference.

packRelease composes oyster/build’s source capture, candidate preparer, compiler and local artifact writer. Pass sourceDirectory as the repository root, an explicit sourceRevision (git:<full commit SHA>), and recipe. Every entry is relative to that captured repository root. Dirty files and external build outputs are not inputs. The result retains release.json plus artifacts/, ready for installRelease. This example packs the Notes example App kept at the root of its own git repository, ./notes-app:

TS
import { execFileSync } from "node:child_process";
import { packRelease } from "@fungi.computer/oyster/pack";
const commit = execFileSync("git", ["-C", "./notes-app", "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
await packRelease({
appId: "app_notes",
sourceDirectory: "./notes-app",
sourceRevision: `git:${commit}`,
recipe: {
entry: "ui/index.html",
assets: {
maxManifestBytes: 1_048_576,
maxObjects: 1024,
maxObjectBytes: 16_777_216,
maxTotalBytes: 67_108_864,
},
assetBase: "./",
maxOutputBytes: 67_108_864,
backend: {
entry: "backend/index.ts",
exportName: "Notes",
compatibilityDate: "2026-09-04",
runtime: { kind: "durable-facet", stateSchema: 1 },
},
},
outDirectory: "./notes-release",
});

The canonical compiler reads the nearest package’s fungi.app declaration, including capabilities and commands for UI-only Apps. Backend Apps declare fungi.backendDefinition; the emitter derives the API and declaration from that definition and fungi.app in the same build that produces the Worker. Handwritten package API methods are refused. Closed source HTML recipes (including Notes, Bonsai and Inkcap) build captured source. kind: "bundled-html" instead accepts committed, ready assets such as bundle/index.html, with an explicit asset policy.

installFromSource accepts appId, recipe, source: {remote, token, sourceRevision} and the destination Oyster’s url, key and installationId. It uses the maintained Artifacts Git capture, the same build/writer composition, then installRelease, and cleans its temporary output. Source credentials and the destination owner key remain separate. installRelease uploads a packed Release and makes one POST /api/installations naming the installation. The host’s one install-or-select owner installs it (201) or, when the installation already exists, switches it to that Release (200); the App keeps its state, and every other refusal is returned unchanged.

Pending installation commands retain their exact baseline, reduced plan and preparation evidence for recovery. Settlement replaces those payloads with the request’s input digest, correlation, settlement time and recorded outcome. The existing Registry alarm deletes superseded settled journal entries in bounded batches after 90 days from their own settlement. The latest settled entry per effective installation is always pinned, including while newer work is pending. Indexed durable deadlines re-arm the same alarm when idle; pending recovery is never age-deleted. Expiry removes App journal history, not canonical installations, Releases, artifact bytes or Watchdog’s private job history. An expired operation is no longer readable; re-admitting its ID is refused as unavailable when Watchdog still retains that job, rather than creating pending work that cannot execute. A new intent needs a new request ID.

A retained successful receipt keeps its original generation, instance and authority-version reference. Its installation is resolved only from the exact current canonical selection; after removal, generation change or selection drift it is null, while the original success remains recorded. Completion helpers return 409 installation_result_unavailable in that case, and the standalone installer does not approve defaults from historical success. Settled history is never an executable recovery command. Stores containing superseded preview journal shapes require separately authorized current-contract republishing, not an in-place conversion.

The shell client lives in its own package, @fungi.computer/nacre: workbench host, command host, keys, commands, window manager, App directory rows and the shell-module contract. Oyster’s page uses it; a shell imports it directly.

@fungi.computer/oyster/page mounts one installed App’s page and connects its sandboxed frame with that installation’s admitted slots. That page is only the framed App: menus, the command palette, status bars and every other piece of desktop chrome belong to the shell.

A shell is not a framed App. When an owner opens the home installation, the page is served with an empty #oyster-shell root and the host’s page script renders the shell there, in the top-level document. The shell runs with the host’s escalated permissions: it reads the installation directory and opens, arranges and closes every App. Choose a shell you trust, as you would choose the host itself.

Child Apps stay isolated. WorkbenchHost.mount(container, launch, events) mounts each App in its own sandboxed, cross-origin frame (appSandbox), inside the container slot the shell hands it. The shell keeps one stable slot per window and moves that window with transforms, so the frame moves, clips to the window’s rounded corners and stacks below shell menus in the same rendered frame. Slots are never re-parented, and a minimized window keeps its frame mounted, so restoring it never reloads the App. A click inside a frame never reaches the shell; the App’s own focus report raises its window. Every child keeps its own configuration and capabilities; nothing granted to the shell is lent to it. Read the home installation’s own handles with oysterShellHandles(readOysterPageConfig()), and connect each child frame with connectOysterApp.

The standalone root chooses its home by installation ID. Oyster core imports no home App: it serves the selected enabled installation, or its installation list when no home is available. @fungi.computer/oyster/root-data adapts the existing installation and verified Release owners into shared workbench metadata and opens the existing SQLite App-storage owner with fresh admission. The deployable Bonsai root mounts Tourtois’s own PlatformControlsView, offering desktop and App settings. Unsupported hosted administration and platform destinations are not offered; Computer inventory and Gill resource reads currently refuse.

The workbench host handle offers memberAvatar() as a read-only projection of the signed-in member’s catalog-validated avatar preference. Consumers refresh that read when host settings change. Account writes remain Hub-owned.

The host calls apply with the complete owner-issued state, then fetch or call with the admitted selection. Both identify the Instance, owner, App, Release and authority version. Oyster persists the version the owner supplied; it never increments another owner’s authority or trusts a guest’s claim to it.

Owner state Runtime behavior
enabled Resolve and activate the eligible backend.
disabled Fence new requests and abort connections while retaining state.
deleted Record deletion, abort, purge state and retain a tombstone.

An exact owner-state retry returns its recorded result. Changed input at the same version conflicts. Older versions are stale. Disable or delete can be the first state delivered. Re-enable requires a later owner operation. A same-schema Release replacement keeps backend state. A changed schema fails with state_schema_migration_unsupported; Oyster invents no migration policy.

createAppInstance is the Effect core. Its host supplies read, write, resolve, abort, purge and connect for one stable Instance ID. resolve returns the selected Release’s verified durable backend: runtime profile, state-schema version, compatibility date, export name and bundled ESM under an immutable digest. Admission takes the lifecycle lock while acquiring a connection; waiting on App code does not hold that lock, so disable and delete can abort in-flight work.

@fungi.computer/oyster/app-storage exports createAppStorage, the SQLite owner for declared App keys. Supply the database connection and an atomic commit callback. open(authorize) binds a member view; openInstallation(authorize) binds lifecycle work to the shared team layer. Each operation rechecks live admission. Member-scoped lifecycle reads and writes reject with owner_required.

The standalone backend receives env.APP_STORAGE only for operations admitted by its verified declaration and approved grants. Export OysterBinding, the one guest binding entrypoint, beside OysterInstance and OysterRegistry from your Worker entry. A root that omits it refuses every backend frame with binding_unavailable. Backend calls admit only the current instance owner; other callers receive owner_required. Reads, writes and resets use the same storage owner and invalidation stream as the UI. On the Hub, the live owner admission also allows an owner’s attached Agent to edit a declared Team layer; losing owner authority refuses the next mutation. Storage events carry the key, scope and revision; they do not identify an Agent.

Writes and resets check the expected revision, declared scopes, size and quotas. After commit, events(key) publishes bounded invalidations; its first null value marks an armed subscription. Installation and membership deletion use purgeAppStorageInCommit and purgeAppMemberStorageInCommit inside the caller’s transaction. appStorageSchemaStatements retains the existing SQLite table names.

Activation is host-gated before selection writes or aborting the old version. The candidate backend loads in a unique isolate over one bounded disposable sibling facet, candidate, with denied test bindings and no outbound network. The lifecycle supplies its already-read prior state schema: first activation uses empty SQLite; an initialized Instance requires the native facets API to copy app into candidate before construction. A host without clone support refuses that check rather than substituting fresh storage. Cleanup aborts and deletes the sibling, and Instance purge also deletes that known name. The copy is a construction test, not a backup or data rollback. It cannot become the live facet and contains no supervisor selection, authority, Team App storage, or live bindings. Native copying and isolation require the qualified runtime proof; the fresh-SQLite Node adapter is not that proof. The host also checks the immutable UI entry in a zero-grant browser session (reportBootPage with checkBootPage): the page opens the pinned /_boot/<release>/ route on the host’s own origin (https, or http only on loopback) with a short-lived capability, and every request to any other origin is blocked. The UI result is reported, never authority: a page that did not mount is failed, and a missing browser, a timeout, unconfirmed cleanup or an unavailable runner is not_checked. An App manifest, callback or healthy flag cannot supply the verdict. This is construction and UI-mount qualification, not an HTTP request smoke, execution against live storage, or a data rewind.

Each check owns one provider session and confirms it closed before reporting; unconfirmed closure reports boot_cleanup_failed, and Browser Run’s keep_alive remains the backstop.

pnpm run test:recovery-copy:celld exercises the actual construction helper with synthetic App state, a successful copy and a constructor refusal. It checks live state preservation, denied bindings/outbound access, supervisor separation and empty storage on reopening the cleaned sibling. It does not prove physical file/stream deletion, restart recovery, or owner selection.

The disposable both-host proof is bash apps/hub/scripts/check-app-boot-native.sh from the repository root, after the affected dependency and Oyster builds. It requires Linux network namespaces, passwordless local sudo, the pinned celld binary and provisioned Playwright Chromium. Inside an unnamed private network it drives a local Chromium through the same checkBootPage both hosts use (a test-only provider in Browser Run’s devtools API shape) and fresh local fixture storage; it does not install a service, contact a live host or exercise an App’s HTTP handler. It checks real construction and ordinary Agar UI mounting through each host’s /_boot/ route, native facet cleanup, closed browser sessions, retained selections on refusal, forged verdicts, wrapper tampering and browser unavailability.

The Hub’s real Team default-provisioning selection path is qualified separately with bash apps/hub/scripts/check-app-boot-native.sh --publication, under the same prerequisites. Its five host-dependent cases use fresh native resources, the maintained artifact writer/readback and immutable R2 object versions. Other domain-test metadata seeds are not host proof. This leg qualifies selection admission, not downstream projection delivery or deployment.

createCloudflareAppInstance keeps supervisor state in a SQLite Durable Object and runs the backend in a named app facet loaded through the Worker Loader. Supervisor metadata and the backend’s database are separate, and a new Release keeps the facet’s database. The loader key includes the Instance ID and artifact digest, so separate Instances of one Release never share guest module scope. Guest code receives only the bindings() its host builds, no ambient outbound access, and requests carry only Content-Type and the host-admitted actor, Computer and resource headers.

@fungi.computer/oyster/build owns the Node source capture, pinned compiler, declaration emission and local artifact writer used by the private Publisher. createLocalCommitSourceCapture captures one Git commit into a scoped snapshot; createAppCandidatePreparer builds it with createLocalBuildExecutor and keeps that commit in the candidate’s sourceRevision. commitPreparedCandidate persists its output through the supplied writer and Release catalog.

Workers use @fungi.computer/oyster/build-contract for the same recipe schemas, normalization, digest and provider-neutral Effect orchestration. That entry has no filesystem, subprocess or compiler imports. Builds execute in Node within the host’s trusted build environment; they do not execute inside Oyster request handlers.

Recipe paths are relative to the captured Git repository root. For an App in apps/notes, use apps/notes/ui/index.html and apps/notes/backend/index.ts; capture does not rebase paths to the App directory. The nearest package.json above the entry supplies fungi.app, plus fungi.backendDefinition for an API derived from the compiled backend.

An App owns its recipe in package.json#fungi.build, alongside its existing page policy (react, stylesheets, and dependency build declarations). Recipe paths in that metadata are relative to the App’s own directory. The Node-only readBuildManifest(packageJson) decodes that one strict contract; appBuildRecipe(manifest, directory) roots the recipe for a captured checkout, or accepts "" for an App at the repository root. Dependency-only metadata is still valid but cannot supply an App recipe. Unknown build fields are refused.

The starter installer reads metadata with git show from the selected commit, not from dirty checkout files. Its roster owns only App identity and source locations. The scaffold’s npm run build deliberately reads saved working metadata before capturing those same saved files. Its template declares the ordinary npm recipe as kind: "source-html", including the backend entry and asset bounds; the command does not carry another copy of that recipe.

Breaking recipe contract: @fungi.computer/oyster/standalone/app-set no longer exposes StarterApp.recipe or recipe values on standaloneAppSet. Callers read the selected source’s package.json with readBuildManifest and materialize it with appBuildRecipe, both exported from @fungi.computer/oyster/build. The workspace metadata policy advances from package-json-fungi-build-v3 to package-json-fungi-build-v4; builders refuse missing or superseded policy metadata rather than guessing a recipe.

Breaking repository-registration contract: the public PUT /api/apps/:appId/build-source route, which accepted an App identity in the path and caller-supplied installation, repository, and recipe fields, has been removed. Use repo-only APPS.register({ repo }), exposed by Bonsai as apps.register({ repo }). The host reads App identity and fungi.build recipe from the immutable main commit’s root package.json through the shared manifest reader; callers cannot override identity or recipe. An existing repository registration retains its App identity on replay. Registration returns the App ID and exact commit without starting a build or installing a Release. No compatibility route or shim remains.

TERMINAL
export OYSTER_URL=https://your-oyster.example
export OYSTER_OWNER_KEY='<your owner key>'
oyster install ./dist --installation-id my-app
# Or name the same Release's manifest:
oyster install ./dist/release.json --installation-id my-app

The CLI delegates to installRelease from @fungi.computer/oyster/pack, using the same uploader as programmatic clients. Artifacts must upload before POST /api/installations. Upload refusal, missing files, and non-OK installation responses exit nonzero. No key is discovered from your home directory, and this command does not provision a Computer or deploy an Oyster.

For repository authoring, use Bonsai’s repository Settings Make this an App (or apps.register({ repo })). Commit the root package.json#fungi.build recipe first; dirty metadata is not registration input. The readonly App ID and editable revision/name come from registration or the latest recorded build. Status can be read using APPS.buildStatus({ repo }) in the captured Git installation; Settings uses that selector after reload. No job returns null. Workflow callers use APPS.buildStatus({ jobId }) for the particular job they started.

Accepted main builds and approval-bounded installation

Section titled “Accepted main builds and approval-bounded installation”

The standalone Instance’s accepted-ref journal delivers the host-attested main push to Bonsai. For a registered repository, Bonsai requests the retained Trellis build-on-merge template through WORKFLOWS. Its published run owns the build, exact-source verification and installation steps and their result cards. An App’s change/merge payload is not push authority. The host’s Workflow-node idempotency key reuses a build job on retry or restart. A local-only Computer returns typed no_backend before creating a new job or starting a machine.

Only an admitted Workflow-node APPS call can install its matching successful build’s recorded Release at the recorded target. Ordinary App-origin installation remains refused. Existing grants may be reused, but new permissions or anonymous exposure return needs_consent without changing the running Release. Registering a repository as an App (Make this an App) is the owner’s consent for its merge Workflow to create that App’s installation when none exists yet. A Workflow-created installation is private, with no grants and no anonymous methods. Later Workflows reuse exactly that approval and can never widen it; every grant still needs the owner’s explicit consent. The build row records the outcome; status reads are read-only and no native alarm resumes installation. Workflow execution owns retries. This template has no review step, review configuration or separate consent ledger.

Breaking installation contract: Bonsai apps.install and APPS.select require the successful build’s jobId alongside installationId and release. The host compares all recorded Release metadata and the target before installation.

Breaking build-port contract: consumers of @fungi.computer/oyster/app-builds must provide OysterBuildComputer.buildBackend(), returning { kind: "hosted" } or { kind: "no_backend" }, alongside runBuild. The preflight reads routing without starting a machine. The build owner’s install(jobId, decide) invokes the host decision only for a successful job with a verified Release and pending installation target; it records that decision on the same job. Terminal installation decisions are not invoked again on replay.

For a source HTML build, that nearest package must itself be a pnpm workspace member, or the root package of a standalone App repository. A nested package outside the workspace inventory fails with WorkspaceBuildError code non_workspace_package; it never inherits the host package’s dependency closure. Notes is an explicit workspace member with its own Agar and Zod dependencies and the builder’s pinned Vite toolchain, separate from Oyster’s host runtime.

The other tracked nested manifests are separate source inputs, not App HTML builds inside their parent workspace:

  • apps/bonsai/demo/koi-pond: copied into its own demo Git repository.
  • packages/create-fungi-app/template: copied to the generated App’s root.
  • services/app-publisher/test/fixtures/size-hello: a backend-only emission fixture, not a workspace HTML build.
  • apps/tui/test/fixtures/fairy-ring-extension and runtimes/local/test/fixtures/{extension-compactor,extension-counter}: installed extension fixtures, not App source builds.
  • packages/computer/container and packages/cordyceps/upstream/{computerd,dofs,rpc}: container/vendored package inputs, not App source entries.
  • templates/vishnu-starter: a standalone project template outside the pnpm workspace.

A captured UI bundle uses kind: "bundled-html", an entry ending in /index.html, assetBase: "./", and an explicit assets policy with maxManifestBytes, maxObjects, maxObjectBytes, and maxTotalBytes. Set backend: null for UI only, or supply a repository-relative backend.entry with the backend runtime, export name, and compatibility date. The compiler joins both outputs from that same snapshot. A backend-only recipe uses its Worker entry as entry and omits backend.entry. First-party HTML recipes retain their catalog-owned compilation steps.

For UI source, omit kind and use a closed HTML recipe entry, such as ui/index.html for Notes at its repository root. It requires assets and assetBase, and accepts backend.entry in the same recipe. The compiler installs the captured lockfile, emits the catalog’s ordered workspace TypeScript projects, compiles declared stylesheets, and builds the source HTML with Vite. Notes uses plain TypeScript; Bonsai uses React and Tailwind. Neither reads prebuilt workspace dist directories or an external UI output directory.

A workspace dependency that cannot build from its tsconfig.build.json names the one package.json script that builds it, in place of TypeScript configs: "fungi": { "build": { "script": "build" } }. The compiler runs exactly that script, at the dependency’s place in the build order, with pnpm run -- <script> in the package directory and no pre/post hooks or implicit install. Only the Computer build (buildTeamCandidate, composed with createLocalBuildExecutor({ packageScripts: "run" })) runs it; the script is arbitrary package code, isolated by that admitted Computer. Every other composition, including packRelease and create-fungi-app, refuses such a source before installing. Unknown fungi.build fields are refused. The policy is pinned as package-json-fungi-build-v4 in the recipe toolchain; an executor refuses a recipe pinned under another policy.

The App’s HTML module scripts, shell module and selected backend select its workspace compile roots. The maintained HTML parser and esbuild follow local runtime imports; erased type imports and authoring scripts do not pull a host package into that compilation. Declared package stylesheets are also roots. The planning pass follows code imports only; the actual Vite build retains ownership of asset loading, validation and emission. Each selected workspace package still builds its full declared products and runtime workspace dependency closure, in dependency order. Dependency manifests, hydration and the Computer-only package-script guard are unchanged.

createLocalArtifactWriter(directory, recipe.assets) writes the App root and all referenced UI/backend objects. It uses the supplied asset bounds and maintained MIME metadata. Bundled UI plus a backend may hydrate a captured pnpm-lock.yaml with the pinned pnpm@11.24.0, frozen lockfile and no lifecycle scripts or pnpmfile. Installs never run lifecycle scripts; the only package script a build runs is a declared fungi.build.script, on a Computer.

For Artifacts, createArtifactsSourceCapture({ remote, token, sourceRevision }) accepts the host’s Git URL and a git:<commit> revision. Native Git receives the credential through its environment, refuses redirects, and captures that exact commit even if the branch advances. A git.push Workflow can prepare the candidate in Node, commit it through the artifact writer and Release catalog, then upload/install the Release through oyster/pack.

Pass your own OysterTenancy from @fungi.computer/oyster/tenant as the tenancy of oysterGitRoot({ tenancy }). Its resolve(request) selects one tenant from the request origin before sign-in and returns that tenant’s TenantScope (built with tenantScope(tenantId, origin)) and authentication, or null to refuse an unknown origin. Oyster uses the scope for registry, workspace, keys, webhooks and repository names, and pins each Instance to its selected tenant. Canonical member roles remain distinct from owner authority. Public App reads remain available; unknown origins and policy refusals fail closed. ownerOrKeyTenancy is the single-tenant case.

@fungi.computer/seam/machine-link-protocol exposes the bounded read/leave decoder and createMachineLinkPostChannel. The channel transports ordinary Carrier Post envelopes and their receiver receipts without rewriting cargo. The host supplies frame-byte, in-flight and acknowledgment-time limits.

machineLinkDialUrl(issuer, teamId, deviceId) and machineLinkAudience(teamId, deviceId) give both hosts the same GET path and Seam audience. Enrollment is separately authorized by the account owner; a native dial proves possession of the enrolled device key, not command acceptance.

The host still owns authenticated native sockets, currentness and connection lifetime. Call close() when a connection is lost; create a new channel for its successor. A closed channel, timeout or capacity failure rejects pending I/O without asserting command refusal or custody. Carrier remains the durable outbox and retry owner. In-flight state is not a persistent mailbox or a Capnweb capability table.

A Post acknowledgment is not Shiitake command acceptance. The Hub’s home-aware Agent mail route still requires the recipient’s actual Accepted receipt. Focused channel and route tests do not qualify native transport, reconnect, Hub restart or Agent execution. The rebuild now wires Local service, Team native receiver and hosted actual-home consumers together; their whole-bundle native qualification and production activation remain separate claims.

After selecting a new Release, the owner can open App access beside the App on the standalone owner home, or visit /api/installations/<installationId>/grants directly. Review the requested capabilities and choose Save permissions. Existing resource restrictions are preserved; a newly selected capability is approved within this Oyster’s scope. This updates the installation without reinstalling the App or changing its data.

The page sends an owner-only PUT to the same path, carrying the captured storeGeneration, authorityVersion, Release artifactDigest, and a bounded {kind:"change-grants",changes:[{capability,grant}]} command. Each grant contains operations and resources; null revokes that capability. Unnamed approvals retain their existing operation and resource ceilings. Agents, keys, visitors and App-origin requests cannot approve. A replaced store, stale Release/version or concurrent approval returns 409; the page refreshes the facts and rebases touched edits for review. The required InstallationStore exposes its durable generation() and compareAndPut(record, expectedVersion, expectedGeneration). The latter returns a serializable commit/refusal result across Durable Object RPC and checks both pins in the existing commit transaction.

App AGENTS.send requires a caller-owned requestId, minted once per user intent and reused unchanged on retry after an UNKNOWN outcome. The standalone adapter no longer generates a replacement. See the App Agent send API reference for the complete input and migration note.