Operate / Persistence

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

WhatConfigured byStarter defaultProduction 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 snapshotsdefineApplication({ data }), a FoundryDataAdapterMemoryFoundryDataAdapter (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 countersdefineAgent({ store }) or defineApplication({ conversationStore }), a Glove StoreAdapternew 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, formsAdapters 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 writesdefineWorkingEnvironment({ persistence })None: the VFS is discarded when the run endsfoundryDataEnvironmentPersistence({ 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 store that returns new 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 InMemoryContextAdapter are 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.

lib/data-dir.tstypescript
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");
lib/conversation-store.tstypescript
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(); }
}
foundry.application.tstypescript
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),
});
agents/support/memory/customer.memory.tstypescript
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),
  },
});
agents/support/agent.tstypescript
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

MemoryFoundryDataAdapterFileFoundryDataAdapter
Survives restartNoYes
Shared with job processesNo (one copy per process)Yes, through the file
HostsOne processOne host; sibling processes coordinate with a lock
Default identifierfoundry-memory-datafoundry-file:<file>
compareAndSetWorkspaceEntryYesYes
Use forTests and throwaway demosSingle-host deployments and serious local development

FileFoundryDataAdapter options

OptionDefaultMeaning
filerequiredPath of the JSON state file. Its directory is created if missing.
identifierfoundry-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.
lockTimeoutMs10_000How long a write waits for another process holding <file>.lock.
staleLockMs30_000Age 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:

foundry.application.tstypescript
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 mode 0600, 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.

GroupMethodsContract
InstancesgetAgent, putAgent, listAgents(definitionId?), provisionAgentprovisionAgent must be atomic on provisioningKey: concurrent calls with one key return one instance. Rehydrate with reconstructAgentInstance.
SubscriptionsgetPlaybookSubscription, putPlaybookSubscription, deletePlaybookSubscription (returns whether it existed), listPlaybookSubscriptions(workspaceId?)Rehydrate with reconstructPlaybookSubscription.
Inbound deliverygetInboundDelivery, claimInboundDelivery, completeInboundDelivery(key, runIds), releaseInboundDeliveryClaim is insert-if-absent: true only for the first caller. Complete fails when no claim exists. Release removes only a pending claim.
ActivationsgetActivation, putActivation, listActivations(workspaceId?)Schedule and sleep records; read at startup to re-arm work.
ConversationsgetConversation, putConversation, listConversations(agentId)Records only; transcripts live in the store.
WorkspacegetWorkspaceEntry, putWorkspaceEntry, listWorkspaceEntries, optional compareAndSetWorkspaceEntryKeyed by (workspaceId, key); see below.
VFS snapshotsgetWorkingEnvironmentSnapshot(owner), putWorkingEnvironmentSnapshot(owner, snapshot)Keyed by owner scope, workspace, definition, agent and (for conversation scope) conversation. Never expose these as workspace entries.
Inbox and tasksputInboxItem, listInboxItems, putTask, listTasksUpsert by id; list by workspace.
EnvironmentlistEnvironment({ 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:

lib/postgres-data.tstypescript
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: null succeeds only if the key does not exist.
  • Otherwise it succeeds only if the stored updatedAt equals 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:

agents/support/claim-ticket.tstypescript
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 a StoreAdapter (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: true only means a store factory is configured. With a MemoryStore factory the transcript reads back empty while still reporting persisted: true.
  • glove-core ships only MemoryStore. The deprecated glove-sqlite package has new SqliteStore({ dbPath, sessionId }) (better-sqlite3); it does not implement newer optional capabilities such as createSubAgentStore. For production, implement StoreAdapter on your database, or extend MemoryStore as 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.

OptionDefaultMeaning
filerequiredA dedicated SQLite file on a local persistent volume. Network filesystems are unsupported.
namespacerequiredTrusted ownership key. Include tenant/workspace and instance identity.
schemarequiredA MemorySchema from glove-memory/core.
busyTimeoutMs5000Cross-process lock wait.
maxSnapshotBytes16 MiBPer-subsystem limit; exceeding it fails without committing.
fuzzySearchfalseEmbedding-free episodic content search.
embeddernoneOptional 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 example cachedRemote from glove-vfs) as the environment’s filesystem and 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

StateAfter restart
Instances, conversation records, subscriptions, workspace, inbox, tasks, VFS snapshotsKept with a durable data adapter; lost with MemoryFoundryDataAdapter.
Schedules and sleepsRe-armed from persisted activations at startup. Paused, completed and cancelled ones stay disarmed.
Transcripts and memoryKept only with durable stores and memory adapters.
Runs that were executingNot 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 approvalsLost: approval records are files in a per-runtime temporary directory (and expire after 5 minutes by default anyway).
Accounts, routes, bindingsRe-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.