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.
Replace the frontend
Section titled “Replace the frontend”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.
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.
Permission is separate from connection
Section titled “Permission is separate from connection”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.
Keep lifetime and backend separate
Section titled “Keep lifetime and backend separate”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.
Add declared backend methods
Section titled “Add declared backend methods”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:
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:
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.
Check and continue
Section titled “Check and continue”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.