Draw it in a client
Share a contract and render the projection as panel rows.
A client must know the extension’s commands and projection, but it must never
import host code or read private state. This page moves that public part into a
contract and adds a client entry that turns the projection into panel
rows. From here on, the Bookmarks files live together in one bookmarks/
folder.
Share a contract
Section titled “Share a contract”import { ExtensionId, type CommandsV0,} from "@fungi.computer/shiitake/extensions";import * as Schema from "effect/Schema";
const Link = Schema.Struct({ url: Schema.String, title: Schema.String });export const Bookmark = Schema.Struct({ ...Link.fields, sessionId: Schema.String,});const commands = { add: { scope: "session", input: Link, output: Bookmark },} satisfies CommandsV0;
/** What a client may know about Bookmarks. It imports no host code. */export const bookmarkContract = { id: ExtensionId.make("bookmarks"), commands, projection: { schema: Schema.Array(Bookmark) },};The contract holds the extension’s ID, its command specs and its projection schema, and nothing else. The host and every client import the same file, so they cannot disagree about the wire shape.
import { Mycelium } from "@fungi.computer/mycelium";import { defineCollection, defineExtension,} from "@fungi.computer/shiitake/extensions";import * as Effect from "effect/Effect";import { Bookmark, bookmarkContract } from "./contract.js";
/** Bookmarks: the complete extension, built on the shared contract. */export const bookmarks = defineExtension(bookmarkContract.id) .state({ bookmarks: defineCollection(Bookmark, { key: (b) => JSON.stringify([b.sessionId, b.url]), index: (b) => b.sessionId, }), }) .define({ prompt: (ctx, run) => { const saved = ctx.state.read((s) => s.bookmarks.page({ index: run.sessionId, limit: 32 }), ); const titles = saved.map((b) => b.title).join(", "); return [{ name: "saved", text: `Saved links: ${titles || "none"}` }]; }, commands: { specs: bookmarkContract.commands, handlers: (ctx) => ({ add: ({ sessionId, input }) => Effect.gen(function* () { const bookmark = { ...input, sessionId }; yield* ctx.state.bookmarks.put(bookmark); yield* ctx.observe(sessionId); return bookmark; }), }), }, tools: (_ctx, _run, commands) => Mycelium.module({ id: "example.bookmarks", name: "bookmarks", operations: { add: commands.tool("add", { description: "Save a link; saving the same URL replaces it.", }), }, }), projection: { schema: bookmarkContract.projection.schema, read: (ctx, sessionId) => ctx.state.bookmarks.peekPage({ index: sessionId, limit: 32 }), }, });The extension is page 5’s, now built from the contract: the same ID, command specs and projection schema.
Render it
Section titled “Render it”import { defineClientEntry } from "@fungi.computer/shiitake/client-entry";import { bookmarkContract } from "./contract.js";
/** Panel rows carry at most 1,024 characters of detail. */const shortened = (url: string) => url.length <= 1_024 ? url : `${url.slice(0, 1_023)}…`;
/** One panel row per saved link, from the decoded projection only. */export default defineClientEntry(bookmarkContract, { contribute: (view) => ({ chips: [], panel: view.status === "running" && view.value !== null ? view.value.slice(0, 16).map((bookmark, index) => ({ id: `bookmark-${String(index)}`, title: bookmark.title, detail: shortened(bookmark.url), })) : [], timeline: { cells: [], omitted: 0 }, waiting: null, }),});defineClientEntry(contract, ...) decodes the projection with the contract’s
schema before contribute sees it. If a slot fails to decode, the entry gets no
value and contributes no rows; one broken extension never stops another from
rendering. A panel shows at most 16 rows, and each row’s text is bounded, so the
entry trims long URLs.
import type { ExtensionRosterV0 } from "@fungi.computer/shiitake/extensions";import { callTool, memoryHost } from "@fungi.computer/shiitake/testing";import client from "./client.js";import { bookmarks } from "./extension.js";import { bookmarkRoster } from "./roster.js";
/** The model saves a link, a person saves another, the client draws both. */export async function bookmarkRoundTrip( extensions: ExtensionRosterV0 = bookmarkRoster,) { const host = await memoryHost({ extensions, script: [ callTool("bookmarks.add", { url: "https://shiitake.shiit.app/", title: "Shiitake docs", }), "Saved.", ], }); try { const session = host.agent.session("reading"); await session.send("Save the Shiitake docs.", { requestId: "save" }); await host.settle("reading"); await session.extension(bookmarks.manifest.id).call("add", { url: "https://fungi.computer/", title: "Fungi", }); const snapshot = await session.read(); return client.contribute?.(snapshot.extensions.bookmarks).panel ?? []; } finally { await host.close(); }}import { bookmarkRoundTrip } from "./bookmarks/run.js";
for (const row of await bookmarkRoundTrip()) console.log(`${row.title}: ${row.detail ?? ""}`);node --import tsx run-bookmarks.tsIt prints one line per panel row:
Fungi: https://fungi.computer/Shiitake docs: https://shiitake.shiit.app/Next: Grant only what it needs.