Persistence
A Foundry app keeps state in four separate places, and each one has its own adapter. Persisting one does not persist the others: a durable data adapter does not make an in-memory transcript store durable, and neither saves an agent's files. The starter templates persist none of the four.
Four boundaries
| What | Configured by | Starter default | Production options |
|---|---|---|---|
| 1. Foundry runtime data: agent instances, conversation records, playbook subscriptions, inbound delivery claims, schedule/sleep activations, workspace entries, shared inbox, tasks, environment values, VFS snapshots | defineApplication({ data }), a FoundryDataAdapter | MemoryFoundryDataAdapter (a fresh one is used when data is omitted) | FileFoundryDataAdapter on one host; your own transactional adapter for several hosts |
2. Glove conversation transcripts: the exact Message history, turn and token counters | defineAgent({ store }) or defineApplication({ conversationStore }), a Glove StoreAdapter | new MemoryStore(...) | A durable StoreAdapter you provide (example below). glove-sqlite’s SqliteStore exists but is deprecated |
| 3. Structured memory: entities, episodes, resources, pinned context, goals, facts, forms | Adapters returned by a defineMemory profile (or goals/facts/forms) | InMemoryContextAdapter (travel concierge) | createSqliteMemoryAdapters from glove-memory/sqlite (Node 22.13+, single host) or your own adapters |
| 4. Working-environment VFS: files the agent writes | defineWorkingEnvironment({ persistence }) | None: the VFS is discarded when the run ends | foundryDataEnvironmentPersistence({ scope }), or a native persistent Vfs such as cachedRemote |
Why in-memory state disappears between runs
Every run executes in its own job process under the managed execution daemon (see Deploy & security). That process imports foundry.application.ts and the agent module again, so anything held in memory exists once per process:
- A
storethat returnsnew MemoryStore(...)starts every run with an empty history. The model sees only the current message, and the inspector’s Chat page cannot reload the transcript. - With
MemoryFoundryDataAdapter, writes an agent makes during a run (glove_foundry_workspace, inbox items, tasks, VFS snapshots) land in the job process’s copy. The control process, the inspector and the next run never see them. Instances and conversations created through the API live in the control process and survive until it stops. - Development memory adapters such as
InMemoryContextAdapterare recreated in every job process, so they do not carry anything from one run to the next.
The adapters below share state through files on disk, which every Foundry process on the host can open. That is what makes multi-turn history, workspace writes and VFS restores work, not only restarts.
A complete single-host setup
These five files persist all four boundaries under one data directory. They were run against a real runtime: history, workspace entries and VFS files carry across turns and across a full restart.
Projects created by glove-foundry init already ship lib/file-store.ts — a FileStore equivalent to FileConversationStore below — plus FileFoundryDataAdapter under .data/. The tutorial shows a simplified 25-line version that saves messages only; the full one also persists turn and token counters, tasks and inbox items, so WebChat's counters and Glove's task tool survive between runs.
import { resolve } from "node:path";
/** Use an absolute path on a persistent volume in production. */
export const dataDir = resolve(process.env.FOUNDRY_DATA_DIR ?? ".data");import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
MemoryStore,
type InboxItem,
type Message,
type Task,
type TokenConsumptionCounter,
} from "glove-core";
interface SavedConversation {
readonly version: 1;
readonly messages: Message[];
readonly turns: number;
readonly consumption: TokenConsumptionCounter;
readonly tasks: Task[];
readonly inbox: InboxItem[];
}
/**
* One JSON file per conversation, rewritten atomically after every change.
* Single host, one active run per conversation. Use a database-backed
* StoreAdapter when several hosts or concurrent writers share transcripts.
*/
export class FileConversationStore extends MemoryStore {
private constructor(identifier: string, private readonly file: string) {
super(identifier);
}
static async open(directory: string, conversationId: string) {
const name = createHash("sha256").update(conversationId).digest("hex");
const store = new FileConversationStore(conversationId, join(directory, `${name}.json`));
await store.restore();
return store;
}
private async restore(): Promise<void> {
let saved: SavedConversation;
try {
saved = JSON.parse(await readFile(this.file, "utf8")) as SavedConversation;
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return;
throw cause;
}
await super.appendMessages(saved.messages);
await super.addTokens(saved.consumption);
for (let turn = 0; turn < saved.turns; turn++) await super.incrementTurn();
await super.addTasks(saved.tasks);
for (const item of saved.inbox) await super.addInboxItem(item);
}
private async save(): Promise<void> {
const saved: SavedConversation = {
version: 1,
messages: await this.getMessages(),
turns: await this.getTurnCount(),
consumption: await this.getTokenConsumption(),
tasks: await this.getTasks(),
inbox: await this.getInboxItems(),
};
const directory = join(this.file, "..");
await mkdir(directory, { recursive: true });
const temporary = `${this.file}.${randomUUID()}.tmp`;
await writeFile(temporary, JSON.stringify(saved), { mode: 0o600 });
await rename(temporary, this.file);
}
override async appendMessages(messages: Message[]) { await super.appendMessages(messages); await this.save(); }
override async addTokens(usage: TokenConsumptionCounter) { await super.addTokens(usage); await this.save(); }
override async incrementTurn() { await super.incrementTurn(); await this.save(); }
override async resetCounters() { await super.resetCounters(); await this.save(); }
override async addTasks(tasks: Task[]) { await super.addTasks(tasks); await this.save(); }
override async updateTask(id: string, updates: Partial<Pick<Task, "status" | "content" | "activeForm">>) { await super.updateTask(id, updates); await this.save(); }
override async addInboxItem(item: InboxItem) { await super.addInboxItem(item); await this.save(); }
override async updateInboxItem(id: string, updates: Partial<Pick<InboxItem, "status" | "response" | "resolved_at">>) { await super.updateInboxItem(id, updates); await this.save(); }
}import { join } from "node:path";
import { FileFoundryDataAdapter, defineApplication } from "glove-foundry";
import { FileConversationStore } from "./lib/conversation-store.js";
import { dataDir } from "./lib/data-dir.js";
export default defineApplication({
name: "Support",
// (1) Foundry runtime data: instances, conversations, schedules, workspace, VFS snapshots…
data: new FileFoundryDataAdapter({ file: join(dataDir, "foundry.json") }),
// (2) Glove transcripts for every agent that does not define its own `store`.
conversationStore: ({ conversationId }) =>
FileConversationStore.open(join(dataDir, "transcripts"), conversationId),
});import { join } from "node:path";
import { Effect } from "effect";
import { defineMemory, foundryGuidanceSubject, type AgentDefinitionSurfaceContext } from "glove-foundry";
import { MemorySchema } from "glove-memory/core";
import { createSqliteMemoryAdapters } from "glove-memory/sqlite";
import { dataDir } from "../../../lib/data-dir.js";
const schema = new MemorySchema().defineEpisodeKind({
name: "interaction",
description: "A customer request or decision, with its source and date.",
});
// Namespace from trusted instance identity, never from model or user input.
const memoryFor = (context: AgentDefinitionSurfaceContext) =>
createSqliteMemoryAdapters({
file: join(dataDir, "memory.sqlite"),
namespace: foundryGuidanceSubject(context, "instance"),
schema,
});
// (3) Structured memory, shared by every conversation of one instance.
export default defineMemory({
description: "What we know about this customer across conversations",
context: { adapter: (context) => Effect.sync(() => memoryFor(context).context) },
episodic: {
access: "curator",
adapter: (context) => Effect.sync(() => memoryFor(context).episodic),
},
});import { createAdapter } from "glove-core/models/providers";
import {
composeAgent,
defineAgent,
defineWorkingEnvironment,
foundryDataEnvironmentPersistence,
} from "glove-foundry";
import { claimTicket } from "./claim-ticket.js";
import customerMemory from "./memory/customer.memory.js";
// (4) Restored before every run and saved after it, one VFS per conversation.
const caseFiles = defineWorkingEnvironment({
options: { limits: { maxVfsBytes: 8 * 1024 * 1024 } },
persistence: foundryDataEnvironmentPersistence({ scope: "conversation" }),
});
export default defineAgent({
description: "Answers customer questions and keeps case notes",
// (3) A memory profile must be in the catalogue AND selected in `memory`.
components: composeAgent(customerMemory),
memory: [customerMemory],
workingEnvironment: caseFiles,
tools: (_agent, ctx) => [claimTicket(ctx)],
// (2) No `store` here, so transcripts use the application's conversationStore.
model: () =>
createAdapter({
provider: "openrouter",
model: process.env.OPENROUTER_MODEL ?? "openai/gpt-4.1-mini",
stream: true,
}),
systemPrompt: "You are a support agent. Keep running case notes in /case.md.",
});claim-ticket.ts is shown in compare-and-set. Add the data directory to .gitignore. In a container, set FOUNDRY_DATA_DIR to a mounted volume (see the container recipe).
Runtime data: memory vs file adapter
MemoryFoundryDataAdapter | FileFoundryDataAdapter | |
|---|---|---|
| Survives restart | No | Yes |
| Shared with job processes | No (one copy per process) | Yes, through the file |
| Hosts | One process | One host; sibling processes coordinate with a lock |
| Default identifier | foundry-memory-data | foundry-file:<file> |
compareAndSetWorkspaceEntry | Yes | Yes |
| Use for | Tests and throwaway demos | Single-host deployments and serious local development |
FileFoundryDataAdapter options
| Option | Default | Meaning |
|---|---|---|
file | required | Path of the JSON state file. Its directory is created if missing. |
identifier | foundry-file:<file> | Adapter identity. |
agents, conversations, subscriptions, activations, environment | [] | Seed records. A seed is inserted only when no record with that id (or, for environment values, scope + key) exists, so edits made at runtime are never overwritten. |
lockTimeoutMs | 10_000 | How long a write waits for another process holding <file>.lock. |
staleLockMs | 30_000 | Age after which a dead process’s lock file is removed. |
MemoryFoundryDataAdapter accepts the same identifier and seed options. Seeds are how you ship a known instance with the code:
import { join } from "node:path";
import {
FileFoundryDataAdapter,
createConversation,
defineAgentInstance,
defineApplication,
} from "glove-foundry";
import support from "./agents/support/agent.js";
import { dataDir } from "./lib/data-dir.js";
export const supportInstance = defineAgentInstance(support, {
id: "support-main",
workspaceId: "support",
context: { tier: "standard" },
});
export default defineApplication({
name: "Support",
data: new FileFoundryDataAdapter({
file: join(dataDir, "foundry.json"),
// Seeds are inserted only when no record with that id exists yet.
agents: [supportInstance],
conversations: [createConversation(supportInstance, { id: "support-main-chat" })],
lockTimeoutMs: 10_000,
staleLockMs: 30_000,
}),
});How the file adapter writes:
- Every mutation takes an advisory lock (
<file>.lock, created exclusively and holding the writer’s pid), reads the whole file, applies the change, writes a temporary file with mode0600, fsyncs it and renames it over the original. A crash leaves the old or the new file, never a torn one. - Mutations within one process are also queued, so concurrent writes never race.
- The whole state, including base64 VFS snapshots, is one JSON document (
"version": 1). Every write rewrites it, so keep VFS limits modest and expect write cost to grow with total state. - It is a single-host adapter. Several hosts need a database adapter with real transactions.
Writing a database adapter
Implement FoundryDataAdapter (exported from glove-foundry). Every method returns an Effect whose error channel is unknown. Records must round-trip through JSON.
| Group | Methods | Contract |
|---|---|---|
| Instances | getAgent, putAgent, listAgents(definitionId?), provisionAgent | provisionAgent must be atomic on provisioningKey: concurrent calls with one key return one instance. Rehydrate with reconstructAgentInstance. |
| Subscriptions | getPlaybookSubscription, putPlaybookSubscription, deletePlaybookSubscription (returns whether it existed), listPlaybookSubscriptions(workspaceId?) | Rehydrate with reconstructPlaybookSubscription. |
| Inbound delivery | getInboundDelivery, claimInboundDelivery, completeInboundDelivery(key, runIds), releaseInboundDelivery | Claim is insert-if-absent: true only for the first caller. Complete fails when no claim exists. Release removes only a pending claim. |
| Activations | getActivation, putActivation, listActivations(workspaceId?) | Schedule and sleep records; read at startup to re-arm work. |
| Conversations | getConversation, putConversation, listConversations(agentId) | Records only; transcripts live in the store. |
| Workspace | getWorkspaceEntry, putWorkspaceEntry, listWorkspaceEntries, optional compareAndSetWorkspaceEntry | Keyed by (workspaceId, key); see below. |
| VFS snapshots | getWorkingEnvironmentSnapshot(owner), putWorkingEnvironmentSnapshot(owner, snapshot) | Keyed by owner scope, workspace, definition, agent and (for conversation scope) conversation. Never expose these as workspace entries. |
| Inbox and tasks | putInboxItem, listInboxItems, putTask, listTasks | Upsert by id; list by workspace. |
| Environment | listEnvironment({ workspaceId, agentId?, conversationId? }) | Return workspace-scoped values plus agent- or conversation-scoped values matching the ids. Read-only through Foundry; never store secrets here. |
The one method most likely to be written wrong is provisioning, because subscriptions call it concurrently:
import { Effect } from "effect";
import {
createAgentInstance,
reconstructAgentInstance,
type AgentInstance,
type ProvisionAgentOptions,
} from "glove-foundry";
// your code: any SQL client; this stub only names the shape used below.
declare const sql: {
query<T>(text: string, values: unknown[]): Promise<{ rows: T[] }>;
};
/** One row per provisioning key, so concurrent callers converge on one instance. */
export function provisionAgent(
input: ProvisionAgentOptions,
): Effect.Effect<AgentInstance, unknown, never> {
return Effect.tryPromise({
try: async () => {
const candidate = createAgentInstance(input.definitionId, input, input.provisioningKey);
await sql.query(
"insert into foundry_agents (id, provisioning_key, record) values ($1, $2, $3) " +
"on conflict (provisioning_key) do nothing",
[candidate.id, input.provisioningKey, JSON.stringify(candidate)],
);
const { rows } = await sql.query<{ record: AgentInstance }>(
"select record from foundry_agents where provisioning_key = $1",
[input.provisioningKey],
);
return reconstructAgentInstance(rows[0]!.record);
},
catch: (cause) => cause,
});
}compareAndSetWorkspaceEntry
compareAndSetWorkspaceEntry(entry, expectedUpdatedAt) is an optional, atomic “first writer wins” update for coordinating agents through the workspace:
expectedUpdatedAt: nullsucceeds only if the key does not exist.- Otherwise it succeeds only if the stored
updatedAtequals the value you read. - A replacement must carry a strictly later, parseable
updatedAt; anything else throws. - A conflict returns
false: re-read and retry. Every writer to that key must use the same contract.
Both bundled adapters implement it; a database adapter should implement it in one transaction (update … where updated_at = $expected, or insert-if-absent for null). Agent code reaches it through the assembly context’s data:
import { Effect } from "effect";
import type { GloveFoldArgs } from "glove-core";
import type { AgentAssemblyContext } from "glove-foundry";
import { z } from "zod";
const input = z.object({ ticketId: z.string() });
/** First writer wins: an atomic claim on a shared workspace key. */
export function claimTicket(ctx: AgentAssemblyContext): GloveFoldArgs<z.infer<typeof input>> {
return {
name: "claim_ticket",
description: "Claim a ticket so only one agent in this workspace works on it.",
inputSchema: input,
async do({ ticketId }) {
const key = `ticket-owner:${ticketId}`;
const attempt = ctx.data.compareAndSetWorkspaceEntry?.(
{ workspaceId: ctx.workspaceId, key, value: ctx.agentId, updatedAt: new Date().toISOString() },
null, // null: succeed only if the key does not exist yet
);
if (!attempt) {
return { status: "error", data: null, message: "This data adapter has no atomic updates." };
}
const claimed = await Effect.runPromise(attempt);
const owner = await Effect.runPromise(ctx.data.getWorkspaceEntry(ctx.workspaceId, key));
return { status: "success", data: { claimed, owner: owner?.value } };
},
};
}This tool runs inside the job process, so it only coordinates anything when the adapter is shared (file or database), never with MemoryFoundryDataAdapter.
Conversation transcripts
- Foundry resolves the store as
definition.store ?? application.conversationStore. Both receive{ definitionId, agentId, conversationId, workspaceId }and return aStoreAdapter(or a promise of one). - The factory is called in the job process at the start of every run, and in the control process whenever a transcript is read (
GET /api/conversations/:id/messages,client.conversationTranscript, the inspector’s Chat page). - With neither configured, runs start with no history and transcripts report
persisted: false. Foundry never rebuilds history from run output or events. persisted: trueonly means a store factory is configured. With aMemoryStorefactory the transcript reads back empty while still reportingpersisted: true.glove-coreships onlyMemoryStore. The deprecatedglove-sqlitepackage hasnew SqliteStore({ dbPath, sessionId })(better-sqlite3); it does not implement newer optional capabilities such ascreateSubAgentStore. For production, implementStoreAdapteron your database, or extendMemoryStoreas above.
Structured memory
createSqliteMemoryAdapters returns entity, episodic, resources, context, goals, forms and facts adapters over one SQLite file, using Node’s built-in node:sqlite. It requires Node 22.13+; Node may print an ExperimentalWarning for SQLite.
| Option | Default | Meaning |
|---|---|---|
file | required | A dedicated SQLite file on a local persistent volume. Network filesystems are unsupported. |
namespace | required | Trusted ownership key. Include tenant/workspace and instance identity. |
schema | required | A MemorySchema from glove-memory/core. |
busyTimeoutMs | 5000 | Cross-process lock wait. |
maxSnapshotBytes | 16 MiB | Per-subsystem limit; exceeding it fails without committing. |
fuzzySearch | false | Embedding-free episodic content search. |
embedder | none | Optional vector search. |
Derive the namespace from trusted identity with foundryGuidanceSubject(ctx, scope). It returns foundry:<scope>:<sha256> over the workspace and instance ids, plus the conversation id when scope is "conversation" (the default). Use "instance" to share memory across one instance’s conversations. Never build a namespace from tool input or message text. Back up a coherent SQLite snapshot (including the WAL), not a copy of the live main file. See Memory and Goals, facts & forms.
Working-environment files
Foundry closes the working environment after every run. With a persistence adapter it loads a snapshot before the run, restores it when you did not pass your own filesystem, and saves a new snapshot when the run ends (emitting foundry.working-environment.snapshot.saved).
foundryDataEnvironmentPersistence({ scope })stores snapshots through the data adapter’s private snapshot methods.scope: "agent"(the default) gives each instance one tree;"conversation"gives each conversation its own.- For large trees or many concurrent writers, pass a native persistent
Vfs(for examplecachedRemotefromglove-vfs) as the environment’sfilesystemand let it own storage and locking. See VFS and Working environments. - Snapshots never contain credentials or host secret stores; supply those again each run. REPL variables are run-scoped and never persisted; put durable artifacts in the VFS.
What survives a restart
| State | After restart |
|---|---|
| Instances, conversation records, subscriptions, workspace, inbox, tasks, VFS snapshots | Kept with a durable data adapter; lost with MemoryFoundryDataAdapter. |
| Schedules and sleeps | Re-armed from persisted activations at startup. Paused, completed and cancelled ones stay disarmed. |
| Transcripts and memory | Kept only with durable stores and memory adapters. |
| Runs that were executing | Not resumed. The live job process is gone; send the work again. |
Run list and status (/api/runs) | Lost. The execution queue lives in memory in the managed daemon. |
Event history (/api/events) | Lost by default: the built-in buffer keeps the last observability.maxEvents (10,000) in memory. |
| Pending approvals | Lost: approval records are files in a per-runtime temporary directory (and expire after 5 minutes by default anyway). |
| Accounts, routes, bindings | Re-created from foundry.application.ts. Routes and bindings added at runtime through the API live in an in-memory store unless you supply a durable services layer. |
Execution-daemon limits (no remote daemon, no job survival after the control process exits, no failover) are covered in Deploy & security.