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 scopeforced to"user"in v0—even if an agent requests"org", the server overwrites itsourceidentifies 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:
- Draft: Agent proposes a candidate memory with
content, optionaltags, and optionalcontexts - Review: Human edits or confirms via desktop modal or chat confirmation
- Persist: Verified payload POSTs to
/v1/memory - Transaction: Server writes the
memoryrow and allmemory_contextrows atomically
The server enforces:
sourceset to the calling integration identifierscopeforced 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:
- Executes
getMemorySearchwith the query string - Receives ranked results with relevance scores and optional contexts
- Uses retrieved memories to ground its response
The response schema includes:
id,content,tags,created_at,updated_atscore(FULLTEXT relevance)contexts[]withsnippet,citation, andorigin
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →