# OpenWork Memory Bank Architecture for Persistent State Management

> Discover the OpenWork memory bank architecture for AI agents to persist and retrieve information using natural language queries and a durable REST API.

- Repository: [Different AI/openwork](https://github.com/different-ai/openwork)
- Tags: architecture
- Published: 2026-08-15

---

**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/main/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/main/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:

```typescript
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:

```typescript
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:

```sql
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/main/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:

```typescript
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/main/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/main/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

```typescript
// 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

```typescript
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`](https://github.com/different-ai/openwork/blob/main/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.