Skip to content
Write an extension

Let people act

Add a command for clients and expose it to the model.

A command is the typed door a person or client uses to change an extension’s state. Now two callers save links, the model and a person, so the save moves into one command and the model’s tool runs that same command.

extension.ts
import { Mycelium } from "@fungi.computer/mycelium";
import {
defineCollection,
defineExtension,
} from "@fungi.computer/shiitake/extensions";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
const Link = Schema.Struct({ url: Schema.String, title: Schema.String });
const Bookmark = Schema.Struct({ ...Link.fields, sessionId: Schema.String });
/** People and the model save links through one command. */
export const bookmarks = defineExtension("bookmarks")
.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: { add: { scope: "session", input: Link, output: Bookmark } },
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: Schema.Array(Bookmark),
read: (ctx, sessionId) =>
ctx.state.bookmarks.peekPage({ index: sessionId, limit: 32 }),
},
});

A command spec names its scope, input and output schemas. scope: "session" means each call targets one Session, and Shiitake supplies its ID; the caller cannot choose a different one. The handler is an Effect that runs inside the command’s transaction: yield* each step, and keep it synchronous.

commands.tool("add", ...) in the tools function exposes the same command to the model. The model’s call runs the same handler in the same kind of transaction, scoped to the Session of the run that called it. A command is a write, so its tool is never replayed (replay: "unsafe"). The plain bookmarks.add operation from earlier pages is gone; there is one way to save a link.

run.ts
import { callTool, memoryHost } from "@fungi.computer/shiitake/testing";
import { bookmarks } from "./extension.js";
/** The model saves one link through the tool; a person saves another. */
export async function commandsRun() {
const host = await memoryHost({
extensions: [bookmarks],
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");
const added = await session.extension(bookmarks.manifest.id).call("add", {
url: "https://fungi.computer/",
title: "Fungi",
});
return { added, snapshot: await session.read() };
} finally {
await host.close();
}
}

session.extension(id).call("add", input) is the person’s door. It decodes the input with the command’s schema and returns the encoded output.

main.ts
import { commandsRun } from "./run.js";
const { added, snapshot } = await commandsRun();
console.log("The command returned", added);
console.log(snapshot.extensions.bookmarks);
TERMINAL
node --import tsx main.ts

The command returns the new bookmark, and the projection lists both links: the one the model saved and the one the person saved.

Next: Draw it in a client.