Skip to content
Build with Fungi

Build an App

Make a small Inkcap fork that lists files through an approved Computer capability.

Build Folder list, a small fork of Inkcap. It connects to its host, asks for file-list permission and shows one page of a selected Computer’s files. The next guide publishes this same fork.

You need a Team you own, a Computer for editing and building, and a source fork of Inkcap. Open App releases in Team settings, choose Inkcap under Platform App, then Create source fork. Select the resulting Team App. Keep its repository and App identity for the build.

An Agent with your approved Team Git access can edit that repository. You can also use Git through the Team’s authorized repository tools. Work in the fork, not the platform template. Keep its captured workspace dependencies, entry document and build recipe.

In the fork, add @tanstack/react-query 5.102.8, @tanstack/react-form 1.33.2 and effect 4.0.0 to the App’s dependencies, then replace apps/inkcap/src/main.tsx with the frontend below. Agar declares React Query as a peer for its Query entry point; Caps declares TanStack Form and Effect as peers for FormField and its validator. The existing index.html supplies inkcap-root, and inkcap.css loads Caps and Inkcap’s screen styles. Agar owns the React root, host handshake, theme, and connection lifetime. React Query owns the folder read, keyed by this App surface and its selected Computer. Call this version Folder list in your commit. The fork keeps its own App ID and existing captured recipe.

The example uses the App SDK’s React and Query entry points, the Computer capability contract, and Caps fields and controls. Opening the file standalone shows an explanation instead of attempting a host connection.

TSX
import { createElement, useState } from "react";
import { describeAppError } from "@fungi.computer/agar/errors";
import {
capabilityQuery,
createQueryClient,
queryKeys,
} from "@fungi.computer/agar/query";
import { mountApp, useStore } from "@fungi.computer/agar/react";
import type { AppMountState } from "@fungi.computer/agar/react";
import { computerList } from "@fungi.computer/agar/computer";
import { Button } from "@fungi.computer/caps/components/button";
import {
FormField,
SubmitFooter,
effectValidator,
} from "@fungi.computer/caps/form";
import { Form } from "@fungi.computer/caps/components/field";
import { Input } from "@fungi.computer/caps/components/input";
import { DataList, DataRow } from "@fungi.computer/caps/components/data-list";
import { useForm } from "@tanstack/react-form";
import { useQuery } from "@tanstack/react-query";
import { Schema } from "effect";
import "@fungi.computer/caps/fonts.css";
import "./inkcap.css";
function FolderList({ app, status }: AppMountState) {
const [permission, setPermission] = useState<
"not-requested" | "granted" | "declined" | "customize"
>("not-requested");
const [permissionError, setPermissionError] = useState<string | null>(null);
const [requestedPath, setRequestedPath] = useState("/");
const context = useStore(app?.context);
const files = useQuery({
...capabilityQuery(
app,
computerList,
queryKeys.of(app, "computer-files", requestedPath),
(handle) => handle.list({ path: requestedPath, limit: 20, offset: 0 }),
),
enabled: Boolean(app && permission === "granted" && context?.computerId),
});
const pathForm = useForm({
defaultValues: { path: "/" },
canSubmitWhenInvalid: true,
validators: {
onSubmit: effectValidator(
Schema.Struct({
path: Schema.String.check(
Schema.isMinLength(1, { message: "Enter a folder path." }),
),
}),
),
},
onSubmit: async ({ value }) => {
if (value.path === requestedPath) {
await files.refetch();
} else {
setRequestedPath(value.path);
}
},
});
function listFiles() {
if (
!app ||
permission !== "granted" ||
!context?.computerId ||
files.isFetching ||
pathForm.state.isSubmitting
)
return;
void pathForm.handleSubmit();
}
async function requestAccess() {
if (!app) return;
setPermissionError(null);
try {
setPermission(
await app.ui.requestAppPermissions({ computers: ["computer.list"] }),
);
} catch (cause) {
setPermissionError(describeAppError(cause).message);
console.error("Folder list permission request failed", cause);
}
}
if (!app) {
return (
<main>
<p role="status">
{status === "connecting"
? "Connecting to the App host…"
: status === "standalone"
? "Open this App through its Fungi host."
: "The App host connection ended."}
</p>
</main>
);
}
return (
<main>
<h1>Folder list</h1>
{permission !== "granted" ? (
<Button onClick={() => void requestAccess()}>
Request file access
</Button>
) : null}
<Form
aria-label="Choose a folder"
noValidate
onSubmit={(event) => {
event.preventDefault();
listFiles();
}}
>
<FormField form={pathForm} name="path" label="Folder path" required>
{(field) => (
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) =>
field.handleChange(event.currentTarget.value)
}
/>
)}
</FormField>
<SubmitFooter
form={pathForm}
label="List files"
disabled={
!context?.computerId || permission !== "granted" || files.isFetching
}
/>
</Form>
{permission === "declined" || permission === "customize" ? (
<p role="status">Permission was not granted: {permission}.</p>
) : null}
{permissionError ? <p role="alert">{permissionError}</p> : null}
{!context?.computerId ? (
<p role="status">Choose a Computer in the App host controls.</p>
) : null}
{files.isFetching ? <p role="status">Loading files…</p> : null}
{files.isError ? (
<p role="alert">{describeAppError(files.error).message}</p>
) : null}
{files.data?.entries.length === 0 ? (
<p role="status">This folder is empty.</p>
) : null}
{files.data && files.data.entries.length > 0 ? (
<DataList aria-label="Files in the selected Computer folder">
{files.data.entries.map((entry) => (
<DataRow key={`${entry.type}:${entry.name}`}>{entry.name}</DataRow>
))}
</DataList>
) : null}
{files.data && files.data.nextOffset !== null ? (
<p role="status">More files are available in the next page.</p>
) : null}
</main>
);
}
const mount = document.getElementById("inkcap-root");
if (!mount) throw new Error("Inkcap root is missing");
mountApp(
mount,
{ name: "Folder list", queryClient: createQueryClient() },
(state) => createElement(FolderList, state),
);

mountApp performs the referrer and host-window checks before connecting. It also owns theme updates, connection errors, React root disposal and the connected App’s cleanup. capabilityQuery checks and resolves the declared Computer capability, converts failures to AppError, and lets Query cancel and cache the read. queryKeys.of scopes the entry to this App surface and selected Computer, so a selection change cannot reuse another Computer’s file list. After access is granted and a Computer is selected, the enabled query loads the first listing for /. Submitting a different path changes the key and loads that folder. Submitting the current path calls the query’s maintained refetch(): after the Computer’s files change, List files shows the new entries; after a transient read error, the same action retries that request. This explicit retry works with createQueryClient’s disabled automatic retries and window-focus refetches, while preserving the reactive App, Computer, and path key. Both the submit button and Enter use the native form’s onSubmit, which calls listFiles. Its eligibility guard runs before TanStack submission and blocks a second read while the current read is pending. Enter before consent, after a decline, or with no selected Computer returns without submitting or attempting a list; the button’s disabled state mirrors the same requirements. Once permission is granted and a Computer is selected, both actions can load, refresh, or retry the current path.

mountApp gives the component an AppRoot only while it is connected. The root exposes the App’s identity, current context and available capabilities. Connecting does not grant file access. The host renders consent and bounds the request by the App’s declared capability ceiling. Inkcap’s captured declaration includes computer.list.

The permission outcome is granted, declined or customize. Connecting does not grant access, so this example starts the file query only after granted and when a Computer is selected. useStore keeps that selection reactive. A new grant may reach the open App just after the consent result; createQueryClient subscribes to capability changes and resets the dependent query when the host publishes the grant. The capability owner still checks each operation, so a revoked grant, changed Computer or disconnected host can reject a read. The query exposes an AppError; describeAppError provides the readable message shown by the App. The permission handler reports its caught cause to the App console.

The SDK decodes the listing. This App renders names as text, shows at most 20 entries and signals when another page exists. The Caps FormField binds the path input to TanStack Form, and Effect Schema supplies its standard validator. Caps Form renders a native form; SubmitFooter renders its submit button. canSubmitWhenInvalid: true keeps invalid input eligible for another submission, and the onSubmit validator checks it again. Pending reads, missing permissions and a missing Computer still disable the action. It does not read file contents, write files, run commands or call a model. A capability handle is scoped to the host’s selected Computer, not to a Computer ID chosen by the App.

The disposer returned by mountApp unmounts the React root, aborts a pending handshake, removes lifecycle and theme listeners, restores the prior theme, and disposes the App connection. Disposal releases the connection and handles; it does not undo work an owner already accepted. Apps that add context subscriptions or register commands release those in their own effect cleanup.

Folder list has no custom backend. Its frontend calls the granted Computer owner. An App needing private state or server execution declares a separate backend artifact and uses the admitted app.backend capability. Do not put provider keys, Team cookies or a direct database binding in the frontend.

The source-fork template remains the starting point for a new App. If that fork needs named backend methods, add a runtime-neutral definition module and point fungi.backendDefinition in its package manifest to it. A Grove fork already contains this convention in backend/methods.ts. UI-only forks need no definition.

For example, a definition at backend/definition.ts can contain:

TS
import * as z from "zod";
import {
defineAppBackend,
defineAppMethod,
} from "@fungi.computer/agar/backend-definition";
export const appBackend = defineAppBackend({
methods: {
greet: defineAppMethod({
description: "Return a greeting without accessing Team resources.",
effect: "read",
requires: [],
input: z.strictObject({ name: z.string().min(1).max(64) }),
output: z.strictObject({ greeting: z.string() }),
handler: async ({ name }) => ({ greeting: `Hello, ${name}` }),
}),
},
});

A sibling backend/declaration.ts, named by fungi.backendDeclaration, contains the build-only projection:

TS
import { appBackend } from "./definition.js";
import {
buildAppApi,
buildAppDeclaration,
} from "@fungi.computer/agar/backend-declaration";
export const appApi = buildAppApi(appBackend);
export const appDeclaration = buildAppDeclaration(appBackend, {
capabilities: [],
handlers: [],
roles: [],
});

The separate Worker entry imports appBackend from that module and exports its facet with appBackend.implement(YourFacetClass). Runtime imports such as cloudflare:workers belong in the Worker entry. Do not import Node builtins or fetch the network while the definition module loads; perform network work inside handlers. The emitter refuses these cases and names the import or evaluation step to fix.

The same builder convention applies to first-party Apps and Bazaar source forks. Customer definitions are evaluated only inside the Computer VM. The build emits the declaration from the same source graph included in the backend bundle. Content hashes and importer-specific resolution checks bind the two builds; the Worker does not embed a second compiled SDK or import the build-only module. For Team builds, the Computer checks the emitted source and backend digests after the build; the Hub strictly decodes the submitted declaration at the trust boundary. Do not hand-write an API table in fungi.app.api, and do not paste generated SDK reference pages into the fork. Declarations do not grant permissions: the Hub and resource owners still authorize each call.

Review the code and commit it to the fork’s main branch. A source commit is not a live Release. Continue in App releases to build, preview and accept this exact fork, as described in the Publish an App guide. In the accepted App, the installation keeps Inkcap’s existing grants. Replacing its Release does not clear permissions.

Before testing consent, open the installed App’s settings and its permissions. In Permissions, set Computer access to No Computer access, click Save permissions and confirm the saved setting. If revocation is pending, wait for it to finish. Refresh the App, click Request file access and approve only listing. Select a Computer in the host controls and click List files. Check that the App returns your own file names.

Before testing decline, clear Computer access and refresh the App again. Decline the request and check that no listing is requested. An existing listing grant can satisfy the request without showing consent, so clear it before each test.