OpenWork Memory Bank Architecture for Persistent State Management

OpenWork implements a per-user memory bank that lets AI agents persist arbitrary information and retrieve it through natural-language queries using a durable, REST-style contract exposed via MCP capabilities.

The OpenWork memory bank architecture provides a server-side persistence layer for AI agents, enabling them to save context across conversations without requiring clients to manage state. All storage and search logic lives in the Den API (ee/apps/den-api), keeping the desktop client thin and capabilities discoverable through the Model Context Protocol (MCP).

MCP Tool Surface for Memory Operations

The memory bank is accessed through auto-discovered capabilities generated from OpenAPI operationIds in [ee/apps/den-api/src/openapi.ts](https://github.com/different-ai/openwork/blob/dev/ee/apps/den-api/src/openapi.ts). Agents discover and execute these tools dynamically—there are no hard-coded commands.

Tool Name HTTP Route Operation ID Required Permission
postMemory POST /v1/memory postMemory mcp:write
getMemorySearch GET /v1/memory/search?q=&limit= getMemorySearch mcp:read
getMemory GET /v1/memory getMemory mcp:read
deleteMemoryById DELETE /v1/memory/:id deleteMemoryById mcp:write

The search-first workflow is central to the design: an agent might discover postMemory by querying for capabilities matching "save a memory," then execute it with a verified payload.

Data Model: MySQL with Drizzle ORM

Two Drizzle-defined tables in [ee/packages/den-db/src/schema/memory.ts](https://github.com/different-ai/openwork/blob/dev/ee/packages/den-db/src/schema/memory.ts) capture persisted state.

Memory Table

The MemoryTable stores core records owned by a single user:

export const MemoryTable = mysqlTable(
  "memory",
  {
    id: denTypeIdColumn("memory", "id").primaryKey(),
    user_id: denTypeIdColumn("user", "user_id"),
    org_id: denTypeIdColumn("org", "org_id"),
    scope: mysqlEnum("scope", ["user","org"]).default("user"),
    content: text("content"),
    source: varchar("source", { length: 64 }),
    tags: json("tags"),
    created_at: timestamp("created_at", { mode: "date", fsp: 3 })
      .default(sql`CURRENT_TIMESTAMP(3)`),
    updated_at: timestamps.updated_at,
  },
  (t) => [index("memory_user_id").on(t.user_id)]
);

Key characteristics:

  • Owner-scoped: every query filters on user_id = principal.userId
  • scope forced to "user" in v0—even if an agent requests "org", the server overwrites it
  • source identifies the creating agent or integration for provenance

Memory Context Table

The MemoryContextTable attaches optional citations and snippets:

export const MemoryContextTable = mysqlTable(
  "memory_context",
  {
    id: denTypeIdColumn("memctx", "id").primaryKey(),
    memory_id: denTypeIdColumn("memory", "memory_id"),
    citation: json("citation"),
    snippet: text("snippet"),
    origin: mysqlEnum("origin", ["active_conversation","searched_conversation"]),
    created_at: timestamp("created_at", { mode: "date", fsp: 3 })
      .default(sql`CURRENT_TIMESTAMP(3)`),
  },
  (t) => [index("memory_context_memory_id").on(t.memory_id)]
);

Foreign key constraints with ON DELETE CASCADE ensure memory deletion removes all associated contexts atomically.

Full-Text Search Implementation

OpenWork v0 uses MySQL FULLTEXT for lexical search rather than external vector stores. The content column is indexed via memory_content_fulltext.

Because Drizzle lacks native FULLTEXT DSL support, the index is created idempotently in both:

  • A bootstrap step (ensureMemoryFulltextIndex)
  • Migration scripts for incremental deployments

The search query uses natural language mode with relevance ranking:

SELECT id, content, tags, created_at, updated_at,
       MATCH(content) AGAINST(? IN NATURAL LANGUAGE MODE) AS score
FROM memory
WHERE user_id = ? 
  AND MATCH(content) AGAINST(? IN NATURAL LANGUAGE MODE)
ORDER BY score DESC
LIMIT ?;

Empty results return HTTP 200 with an empty array—never an error—keeping agent error handling simple.

Save Flow with Human Verification

The memory bank follows a human-in-the-loop pattern:

  1. Draft: Agent proposes a candidate memory with content, optional tags, and optional contexts
  2. Review: Human edits or confirms via desktop modal or chat confirmation
  3. Persist: Verified payload POSTs to /v1/memory
  4. Transaction: Server writes the memory row and all memory_context rows atomically

The server enforces:

  • source set to the calling integration identifier
  • scope forced to "user"
  • Maximum content length and context count limits

Retrieval Flow for Natural-Language Queries

When a user asks a question like "what did I decide about Acme?", the agent:

  1. Executes getMemorySearch with the query string
  2. Receives ranked results with relevance scores and optional contexts
  3. Uses retrieved memories to ground its response

The response schema includes:

  • id, content, tags, created_at, updated_at
  • score (FULLTEXT relevance)
  • contexts[] with snippet, citation, and origin

Desktop Client Integration

The OpenWork desktop app surfaces memory bank functionality through controlled UI elements.

Feature Flag Control

A client-side toggle in [apps/app/src/react-app/state/feature-flags-preferences.ts](https://github.com/different-ai/openwork/blob/dev/apps/app/src/react-app/state/feature-flags-preferences.ts) controls UI visibility:

featureFlags: {
  memory: boolean; // Controls Memory panel visibility
}

The underlying routes remain callable regardless of UI state—the flag is presentation-layer only.

Memory Panel Components

  • List view: Displays owned memories with deletion (optimistic UI with Sonner toast undo)
  • Content rendering: Safely escaped text display
  • Copy-prompt button: Injects the static "## Memory Bank" prompt snippet for agents lacking native MCP support

The prompt snippet is defined in [apps/server/src/openwork-runtime-config.ts](https://github.com/different-ai/openwork/blob/dev/apps/server/src/openwork-runtime-config.ts) and appended to system prompts when the memory capability is available.

Security and Privacy Model

Layer Implementation
Access control Owner-scoped on every request; [memory-ownership.test.ts](https://github.com/different-ai/openwork/blob/dev/ee/apps/den-api/test/memory-ownership.test.ts) regression tests prevent cross-user leakage
Data at rest Plaintext in v0—agents must not store secrets, credentials, or PII per prompt guidance
Input validation Content length limits, context count bounds, rate-limiting deferred
Encryption roadmap Documented future requirement for encryptedTextColumn usage

Client Code Examples

Saving a Memory

// With OpenWork MCP client
await agent.executeCapability({
  name: "postMemory",
  body: {
    content: "Deployed version 1.2 to production via den-worker-proxy",
    tags: ["deploy", "production"],
    contexts: [
      {
        snippet: "den-worker-proxy output: 200 OK, 45ms latency",
        conversation_id: "c123",
        message_id: "m456",
        origin: "active_conversation"
      }
    ]
  }
});

Searching Memories

const response = await agent.executeCapability({
  name: "getMemorySearch",
  query: { q: "deploy production", limit: 10 }
});

for (const memory of response.results) {
  console.log(`[${memory.score.toFixed(2)}] ${memory.content}`);
}

Architecture Evolution Path

The v0 implementation establishes the API contract while deferring advanced features:

Future Enhancement v0 State Planned Implementation
Semantic/vector search MySQL FULLTEXT (lexical only) Embeddings + hybrid ranking
Encryption at rest Plaintext + prompt warnings encryptedTextColumn integration
Organization-wide memory scope forced to "user" Enable "org" with ACL layer
Server-side feature gates UI-only toggle Server preference + kill-switch
Memory editing (PATCH) Not implemented Full CRUD with audit trail

Summary

  • MCP-native design: Memory capabilities are auto-discovered, not hard-coded, enabling agent flexibility
  • Owner-scoped MySQL storage: Two-table schema in Drizzle with forced user isolation and cascade deletion
  • Lexical search with FULLTEXT: No external dependencies; relevance-ranked results via native MySQL
  • Human verification workflow: Draft-review-persist pattern prevents unilateral agent state mutation
  • Client-side feature flagging: UI visibility controlled independently from API availability
  • Extensible contract: v0 establishes stable endpoints for future semantic search and encryption upgrades

Frequently Asked Questions

How does OpenWork prevent agents from accessing other users' memories?

Every database query and API route enforces user_id = principal.userId filtering. The memory-ownership.test.ts regression test suite specifically validates that no cross-user data leakage can occur, even with crafted requests setting org_id or scope: "org".

Why does OpenWork use MySQL FULLTEXT instead of a vector database?

The v0 architecture prioritizes operational simplicity: MySQL FULLTEXT requires no additional infrastructure, provides adequate lexical relevance for many use cases, and keeps the deployment footprint minimal. The API contract (/v1/memory/search) remains stable for future hybrid or pure vector implementations.

Can agents save memories without human approval?

No. The OpenWork memory bank architecture requires explicit human verification before persistence. The agent drafts a candidate, the human reviews and potentially edits it through a desktop modal or chat confirmation, and only the verified payload reaches POST /v1/memory. Server-side enforcement of this flow is planned for later versions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →