Skip to content
Concepts

Concepts

Effect-native durable Session acceptance and bounded run orchestration

Shiitake runs durable Agent conversations. A Session holds accepted work, messages and observations, while the host supplies storage, models, tools and the wake that runs queued work.

There are three ways to run it:

  • In your code: install this package and host Agents yourself, on Node for tests or in a Cloudflare Durable Object.
  • On your machine: the fungi CLI is a Shiitake Agent with a terminal interface.
  • Hosted: Fungi runs Agents for your Team, and its models work from your own host too.

To add your own behaviour, start the extension guide at Your first extension.

We don’t take VC money. Fungi is built in the open and proudly backed by its community.

Both local and HTTP Sessions expose session.withdraw(requestId): Promise<WithdrawResult>:

TS
type WithdrawResult =
{ status: "accepted"; commandId: string } | { status: "unknown" };

Shiitake resolves this inside the admission owner’s transaction. An existing admission wins and is not cancelled. Otherwise the owner commits a durable withdrawal fence; every later admission under that Session and request ID is refused, including after owner restart. Calls are idempotent and Session-scoped. A recorded operator line also wins over withdrawal, with its durable entry ID.

Restore a pending draft only after unknown from this committed withdrawal, never after a failed transport or an empty observation page. If recovery itself fails, retain the request ID and exact text and retry recovery when connected. A user retry after withdrawal needs a new request ID. Recall can use this same primitive, then call cancel({ commandId, onlyIfPending: true }) for an accepted queued command; withdrawal itself never cancels admitted work.

Observation messages for prompts and operator lines carry requestId; pending prompts carry it too. Clients can therefore reconcile watch-first delivery without waiting for the command response or matching text.

An accepted prompt returns status: "accepted" and an owner-issued commandId after the durable commit. Repeating the same request identity and input replays the receipt. Changing the input under that identity is a conflict.

The runtime wakes reconciliation, acquires run resources, executes the model and tools, releases resources and records settlement. The host owns provider configuration and retry policy. Shiitake owns durable Session facts and work.

session.read() returns a bounded baseline. session.watch() owns a baseline and live changes resource. Close the watch when its consumer leaves. Addressing a Session performs no I/O. A client uses createShiitakeClient from the /client entry with the host’s authorized transport. Releasing that client does not cancel host work.

session.readSettlement(commandId, { timeoutMs, signal }) returns that exact command’s CommandSettlement (terminal, answer, usage and tool-call count), independent of the selected transcript branch. timeoutMs defaults to zero and is bounded to 30,000 ms. A pending command returns undefined at the deadline; an unknown command fails. Aborting the read never cancels the command. agents.wait uses this read and settlement hints, not a watch or a transcript read. In-process and HTTP clients share the contract; the HTTP encoding is GET /sessions/:sessionId/settlement?commandId=...&timeoutMs=..., with null for a pending command.

Cancellation targets an accepted commandId. too_late is an ordinary outcome when that work can no longer be withdrawn. session.abort() atomically requests cancellation of the running turn and every queued prompt in that Session. Cancelled prompts retain their text for resubmission. Neither a cancelled observation nor a closed browser proves a tool has stopped.

Use defineExtension("id") from /extensions. The version defaults to 0.0.0; pass { id, version } when a behaviour change is not visible in the schemas, config or requested capabilities (for example a fixed hook). The version joins those in the extension’s revision; a recorded stop persists until the revision changes or the host resumes the extension. Configuration, collections and documents are declared before callbacks. .state({ name: defineCollection(...) }) declares state version 1; pass { version, collections, migrations } when a row schema changes. The host’s grants are separate from the definition’s requests. Product feature names belong to the host roster.

context.state.read((s) => ...) reads rows synchronously outside a transaction, for example in a prompt section; context.state.update writes. A projection’s read returns the value itself (or an Option; undefined publishes none). The tools builder’s third argument exposes a declared command to the model: commands.tool("add", { description }) is a Mycelium operation that runs the same handler in the same transaction, scoped to the run’s Session.

Commands, settled hooks and timer hooks run synchronously within the owner transaction. Prompt and tool builders run outside that write transaction. Activation resources use the Effect scope. An extension failure records a stop reason. Resuming it replays missed settled work through its cursor.

context.send({ sessionId, requireExistingSession: true, ... }) opts into an existing target: the admission transaction rejects an absent Session with AdmissionError reason closed, without creating it. Leave the condition out when creating the addressed Session is intended. Automated continuations use the condition to prevent recreating a Session after deletion.

An extension declaring attention: true can request the attention grant. context.attention.set(sessionId, item) and clear(sessionId, id) share its owner transaction and publish after commit. Items have id, kind, summary (at most 200 characters), since and optional expiresAt; each Session carries at most 64. An owner may retain at most 4,096 outstanding items across Sessions; clearing releases capacity. Public observations qualify ids with extensionId. Open attention makes the inbox needs_you, ahead of running or settled work. Stopped owners are excluded until resumed, and owners without a current grant are excluded until the grant returns. Expiry is display metadata; the extension’s timer owns clearing, and restarting does not discard pending attention. Clearing an absent item, including after Session deletion, is idempotent; setting an item requires an existing Session.

Clients bind an extension contract with bindSessionExtension or bindAgentExtension from /client. Projection slots describe observed state, not assumed completion. See the prompt/tool test, state binding test and close test.

An extension may request the exclusive files grant and return Knapsack-minted FileTools from files(context, run). Core captures the tools once per run, passes them to Mycelium beside execute, and records their names in the execution protocol digest. A second granted holder is refused. This contribution adds no shared Files module to execute; explicit Computer capabilities remain independent.

Shiitake holds no permission logic. Tool authors label each operation read or write (and optionally confirm); the host owns every grant decision and delivers it down as versioned data, and Mycelium decides each call. tools.access.grants is the Agent-wide default. The in-process owner returned by Shiitake.make delivers one Session’s grants with agent.grants.deliver(sessionId, { version, grants }), synchronously; code inside a Cloudflare owner also calls deliverSessionGrantsToStorage while its runtime is cold. Versions never move back: an older version is refused, and the same version with other grants replaces the stored ones, so a host that recomputes its decision never leaves the old grant in place. Delivered grants override the default key by key, and Mycelium reads them at every call, so a change applies to the very next call, including in queued, recovered and extension-started runs. Grants are keyed by module id; a module an extension built is granted by its roster grant’s grantKey (the extension id by default), and Files direct tools by files. Keys listed in access.required fail closed: a Session with no delivered or default grant for them is refused. With no access data at all, every tool keeps full access, so a first-time host writes no permission code; a confirm operation is still refused unless the host supplies access.confirm. A Files extension still requires a host tool runtime. Extension tool authority and final external access remain host decisions.

Tool display payloads use SessionMessage.data, directly, not value.details or a data.details envelope. Woodstock stores the same payload on each tool outcome/completed-message entry’s data; model context and compaction inputs use only the message content. Recovery can reconstruct a tool’s application result without putting its display payload into the next model request. Images remain model image content; their display metadata is application data. Live output counters use the tool event’s result.data.

data is independent of host meta even when their keys overlap. It is not host attribution and does not inherit the 4096-byte metadata cap. The ordinary bounded Session read still accounts for the complete projected payload. Woodstock schema 3 refuses older preview stores instead of migrating them on open; the host retains custody of incompatible storage. This is a storage contract, not permission to reset or migrate any live data.

A host attaches its own data to a prompt or operator line, such as who sent it or which channel it came from, with agent.withMeta(meta). Shiitake checks the value against the host’s meta.schema, caps it at 4096 bytes of JSON, stores it beside the message and returns it as SessionMessage.meta. It does not own the metadata’s domain grammar. Only the in-process owner returned by Shiitake.make has withMeta: no request body can carry metadata, because every command schema is closed. Over HTTP the host’s own middleware sets the shiitakeMeta Hono variable, and the session resolver receives it:

TS
const routes = createShiitakeHttpRoutes(
(id, meta) => (meta === undefined ? agent : agent.withMeta(meta)).session(id),
agent.listSessions,
agent.createSession,
agent.deleteSession,
() => agent.extensions,
);
const app = new Hono<ShiitakeHttpEnv>()
.use(async (c, next) => {
c.set("shiitakeMeta", await authenticatedMeta(c.req.raw));
await next();
})
.route("/", routes);

Every accepted command takes the same path: a prompt, a steer, an operator line, a cancel and an abort each store their metadata on the entry they write, so a restart loses none of it. A cancelled run’s SessionTerminal.meta is its cancellation’s metadata. Addressed mail work stores the metadata its host passes to ShiitakeHost.acceptMail(mail, meta); a mail’s sender cannot set it, and a reply never carries the settled work’s metadata (see Delegated replies). A retried request id replays only with the same metadata. A Cloudflare owner exposes its whole HTTP surface (Session routes and ACP) as the protected shiitakeApp() Hono app, so a host mounts it behind its own identity middleware; ACP receives each request’s metadata, so a host can serve each author over withMeta. The host’s metadata schema may be an Effect schema or any synchronous Standard Schema, such as Zod 4.

For a multi-author host, meta.identityPath selects the host’s stable principal ID using SQLite’s JSON decoder (for example, $.author.id). On a withMeta-bound Session, latestPending() returns just that principal’s newest owner-admitted pending prompt, or null. It is independent of the bounded oldest-first pending page and preserves the exact text, supported images and metadata. Steers and follow-ups are eligible; extension work, compactions and started commands are not.

The same identity check protects cancel and raw command({ type: "cancel", ... }), including Accepted replay after reopening. Display names are not identity. The host must authenticate and bind each request, and recheck live Agent/Session access before forwarding it. An unbound in-process owner retains operational cancellation authority. A single-user Local host need not configure identityPath. Pending-only cancellation remains cancel({ commandId, onlyIfPending: true }); lookup does not reserve the work, so a claim race can still return TooLate. Host metadata tests cover each rule.

A ShiitakeDO subclass serves its own routes in front of Shiitake’s by overriding the protected hostFetch(request): it returns a response, or undefined to let Shiitake serve the request.

Cloudflare hosts may supply initialize(extensions) to commit an ordinary Agent-scope extension command before queued or host work resumes. Failure closes the incomplete runtime and leaves its durable work for a later open.

Cloudflare hosts may configure hostJobs to open a concurrent Watchdog lane. Their hostWork pass stages and recovers delivery, then calls that lane’s tickConcurrent. Every wake requests Agent reconciliation and host work independently; both share one durable alarm. During a live tool, its armed five-second heartbeat requests delivery processing after an attention write. Receiver duration and ordinary retry policy still apply. A failed pass retains its retry until that pass succeeds, and close joins both active passes.

@fungi.computer/shiitake/node keeps one durable Agent in a SQLite file on disk, on the Node event loop. openNodeShiitake({ path, ... }) takes the same model, instructions, extensions and tools as Shiitake.make, holds an exclusive lease on the file until close, and returns the in-process agent plus its Session routes as a Hono app to serve behind your own middleware. Reopening the file resumes accepted work. nodeRuntimeHost and acquireNodeStoreLease are the same pieces for a host that composes its own store. See Run it on Node and the Node host tests.

The bound runtime’s pending-work answer includes BirdDog’s Carrier-owned reply retries. Its next wake is the earliest extension timer or owed reply delivery, so an otherwise idle reconciliation cannot erase a mail retry timer. Reopening before a retained reply’s due time schedules its future delivery without a client request.

The agent-control source offers agents.send({agentId, sessionId, requestId, text, delivery}) and agents.recall({agentId, sessionId, requestId}) only when the host supplies the corresponding request-owning callback. Choose a nonempty caller request ID of at most 256 UTF-8 bytes before sending; reuse it for retries or recovery after a lost acceptance response. The host scopes it to the sending Agent and invoking Session and the target Agent and Session. Model input cannot supply the invoking Session or override the pending-only recall guard. Local replay uses that caller identity, not the current tool ID or model program bytes. Recall cancels only pending work: accepted names the known command, too_late names active or settled work without aborting it, and unknown carries no invented command ID. An unknown recall still retains the target owner’s late-admission fence.

The /agent-control source exposes agents.create({requestId, name?, model?, reasoning?}), agents.setModel({agentId, model, reasoning?}) and agents.models({}) when its host authorizes those operations. name labels the new Agent and reasoning (minimal to max, one of modelReasoningLevels) needs a model. agents.create returns the Agent’s name, the requested model and reasoning (the stored default; absent when it inherits), and the workingDirectory its tools use; agents.list reports the same facts for existing Agents. A host that can archive also accepts archived: true there to list only the archived ones (other hosts refuse that input). A host can offer agents.archive({agentId}) and agents.unarchive: an Agent archiving an Agent it created hides it from lists and delegation without deleting anything, and the host refuses any other target, the caller itself, or an Agent with a running command or delegated work still waiting. Each refusal, and a send to an archived Agent, reaches the caller as a CapabilityFailed diagnostic such as archive:not_creator: …, archive:self, archive:running, archive:pending, archive:unreadable (its store predates the schema) or archive:archived. agents.wait accepts timeoutMs from 1 to 30000. Models use provider/model spelling and must be offered by that host; agents.models returns at most 200 offered addresses from signed-in or configured providers. A host model refusal reaches the caller as a CapabilityFailed diagnostic such as model:rejected: …, while other failures propagate unchanged. The /model-preference resolver orders explicit Session, explicit Agent, the person’s last manual switch, Team default, then available login. Hosts own those stored layers and credential admission: choosing a model never bypasses an owner’s Team or Agent key-source restriction. A host can refuse an unrunnable send before acceptance; agents.wait includes the failed run’s bounded diagnostic so callers can explain login or model-selection problems. For a completed command, agents.wait also returns the command’s final assistant text, at most 16,384 characters, with truncated set when it was cut; failed, cancelled and interrupted commands return no text, and agents.read stays the full bounded Session read. Hosted ACP sessions use the Agent and Team layers; they do not select a person’s saved model preference.

A host selects client entries separately from its durable extension roster. defineClientEntry(contract, callbacks) captures the projection decoder for the extension. The installed entry has only protocol, id, optional attach, and optional contribute. At least one callback is required. The host admits exactly shiitake.client/v1; it does not accept the superseded client shape.

contribute is a synchronous, pure function of the extension’s typed view. It returns display-only chips, pinned panel rows, committed timeline cells, and an optional waiting summary/cancel action. It has no renderer nodes, subscriptions, Session transport, or action closures. Parakeet evaluates changed slots once outside rendering and decodes the result at that seam. Invalid output retires that entry’s rows and reports its error without disabling other entries.

This illustrative author uses the same factory exercised by test/client-entry.test.ts:

TS
import { ExtensionId } from "@fungi.computer/shiitake/extensions";
import { defineClientEntry } from "@fungi.computer/shiitake/client-entry";
import * as Schema from "effect/Schema";
const contract = {
id: ExtensionId.make("progress"),
commands: {},
projection: { schema: Schema.Struct({ title: Schema.String }) },
};
export default defineClientEntry(contract, {
contribute: (view) => ({
chips: [],
panel:
view.status === "running" && view.value !== null
? [{ id: "progress", title: view.value.title }]
: [],
timeline: { cells: [], omitted: 0 },
waiting: null,
}),
});

Attachment authors may return void, a Promise, or a scoped Effect. The context supplies activity, signal, and onError; it supplies no Session authority. Each for await over activity creates an independent subscription with one sliding latest-value slot. A slow consumer sees the latest value. The selected Session’s detach aborts the signal and ends every iterator, discarding buffered activity. Ordinary async authors use that signal for their own resource cleanup.

Actions are { id, label, input? } data from @fungi.computer/gills. Their IDs must occur in the same entry’s running advertised Whistle catalog, and input must be a JSON object. Hosts execute the catalog command with its existing prompts and notices; there is no second action registry. Offline and stale actions refuse locally. Waiting cancellation preserves notices but rejects prompts without opening a dialog. Browser rows use ordinary buttons. The TUI renders current extension rows compactly below its footer hints; each row moves the footer upward. Loop and its next run share one row. Ordinary notices can be dismissed with Esc after interactions, queued messages, active work and extension waiting controls have priority. Live extension state is not dismissible. In the TUI, the Row actions palette command uses the existing keyboard choice dialogs. Rows may also carry a typed Session open target. The host supplies the generic open callback and authorizes that target before navigation. The TUI opens the target through its pane registry and reports a refused open as a notice.

V1 permits four chips, sixteen panel rows, thirty-two timeline cells, and two hundred rows total per entry. Labels contain at most 4,096 characters. Chips carry no actions or open targets. ClientChipV1.compact is optional label data that browser and TUI renderers do not display; they use the chip row’s title. A cell’s ID is its positive safe integer sequence written as decimal text. Producer history commits in the same owner transaction as its state change, deduplicates unchanged state, and retains at most thirty-two events and 32 KiB with an explicit omitted count. A real command ID anchors cells after its last loaded durable row; an unloaded anchor stays hidden until its page loads. Alternatively, a cell may supply afterSequence with commandId: null to choose a fixed committed Session position. This is not its producer-local event sequence. Zero places it before the first Session entry; unloaded positions stay hidden until their page loads. Without either anchor, a cell appears under Extension changes. An event may not choose both anchor kinds.

Goal uses the fixed-position addition: its host reads ctx.sessionPosition inside the ending transaction and persists that position. Later output from the same running work cannot move the ending line. Renderers resolve it using the same row visibility policy and split tool groups at that position.

The frozen client V1 contract includes fields, placements, bounds, identities, ordering, admission, dispatch, detach, and history laws. Host extension V0 and client presentation V1 are independent protocols. A breaking client change requires a new client protocol; supported additions require an explicit contract decision and proof. Producer schema changes bump their own extension/state versions. Pre-launch data uses the current contract, without a dual reader.

@fungi.computer/shiitake/fleet owns Agent directory reservations, replay receipts, erasure addresses, granted Session operations and hosted BirdDog delegation. Hosts supply grants containing Agent scope, actions, expiry and provenance. Fleet operations do not read Team, billing or broker policy. Usage has no fleet cap; the host’s inference admission remains the credit authority.

createAgentDirectory(sql) runs mutations inside the caller’s transaction and returns lifecycle facts so host projections commit with the directory change. createFleetSessions(open) lists, creates, reads and deletes Sessions, and send(grant, agentId, sessionId, message) admits replayable work. An omitted Session is created with the message’s request identity. createFleetDelegation({storage, deliver, open, readSettlement}) records the grant’s parent Session and freezes BirdDog mail before delivery. start(grant, task) returns the admission receipt; delegate(grant, task) returns Agar Pending until the exact command settles, then its terminal, final text, native usage facts and tool-call count. The host-only readSettlement(sessionId, commandId) reads exact work rows, independently of transcript pagination and branch selection. Poll with the same request identity and task. Closing a client never cancels work.

Host metadata stays opaque. An in-process open adapter applies it with agent.withMeta(meta); a remote host attaches it at its authenticated transport edge. Cancellation returns the cancellation entry’s metadata, allowing the host to name the canceller in the reply text without making them its author.

A followUp delegation returns {status: "accepted", commandId} at once. The child’s result arrives later as one new user-role line in the parent Session. It is ordinary follow-up work: an idle parent wakes a new run, and an active parent queues it behind the running work, which it never interrupts, so it runs next. Each fact on that line has one setter:

Fact Setter Rule
Content BirdDog settlementReplies The child’s last assistant text, or the host’s cancellation text such as Work cancelled by Ada., verbatim; with neither, one sentence naming the outcome.
reply.commandId BirdDog, from the child’s accepted command Settled work replies carry the parent’s agents.send receipt commandId; undeliverable replies have no command ID.
reply.outcome, reply.diagnostic BirdDog, from the exact Watchdog outcome and the child Session’s terminal diagnostic completed, failed, interrupted, cancelled and outcome_unknown stay distinct. Only failed, interrupted and outcome_unknown carry a diagnostic, bounded by boundedDiagnostic. undeliverable instead carries recipient_offline or recipient_left.
Authentication fleet.authenticateReply(mail) at the parent’s host The key is the frozen send’s <key>:reply:0, replyTo is empty, and from/to mirror the frozen send exactly. Settled replies correlate with the receipt’s commandId; undeliverable replies correlate only by the frozen mail key and typed reason. Otherwise the result is undefined and the host refuses the mail.
Author and meta.delegation.reply The parent’s host, only after authentication The author is the replying child Agent. The settling actor, a human canceller and the parent never author a reply.
Model-visible provenance Shiitake, from the reply the host passes to acceptMail(mail, meta, reply) The model sees the line inside <fungi_agent_reply agent_id=… session_id=… [command_id=…] outcome=… [diagnostic=…] [reason=…]>.

The provider role stays user. Shiitake renders the tag from the verified facts, never from the text, and neutralizes the tag in every other user-role line (typed prompts, unverified mail, operator lines) and inside the reply body. Typed or mailed text that copies the tag therefore never reads as a verified reply. The stored line keeps the child’s text verbatim; clients join it to the send on meta.delegation.reply.commandId through agar’s messageDelegationReply, never on its text. acceptMail refuses a reply that does not name its mail’s sender or that arrives on mail with a replyTo.

Apps exposing an Agent extension import agentExtensionMethods from @fungi.computer/shiitake/agar-extension and spread the result into Agar’s defineAppBackend({methods}). Agar itself has no Shiitake dependency.