The architecture. For how to run a knowledge base — spaces, review dates, approval, publishing — read Knowledge base instead.
Three closely related modules that share a single AI metadata pipeline:
- Documents — rich-text docs grouped into Document Spaces
- Drive — file browser (S3-backed) with folders and previews
- Knowledge Graph — entities + relationships extracted from documents and other content, surfaced as a queryable graph
Field tables are deliberately absent from the Documents section below. The version of this page they replaced listed four columns that do not exist —
is_public,public_slug,version_count,last_edited_at— and pointed at a frontend directory ((app)/documents/) that was never the path. A hand-written mirror of a schema has nothing keeping it honest, and this page is published publicly. Readmodels/documentation.py; it is the only copy that cannot drift.
Documents#
Routers#
| Router | Purpose |
|---|---|
api/documents.py | Document CRUD, versions, comments, code links, docx |
api/document_spaces.py | Space management, membership, roles |
api/document_governance.py | Lifecycle, audit, analytics, export, publishing |
api/document_import.py | Notion / Confluence import jobs |
api/collaboration.py | The collaborative editing socket |
api/kb_portal.py | The public portal — unauthenticated, reads published snapshots only |
Models#
All in models/documentation.py unless noted.
Document is the page: a TipTap body, or — when content_format is docx —
a Word file in object storage with its extracted text in content_text, which
is what lets search, embeddings and the knowledge graph stay format-agnostic.
It nests through parent_id, files under a DocumentSpace, and carries its
own visibility, ownership, review dates and soft-delete columns.
Around it:
| Model | What it holds |
|---|---|
DocumentSpace / DocumentSpaceMember | Sections and who belongs to them, with visibility deciding whether membership is the access list |
DocumentVersion | A full snapshot per content change, pruned by a retention sweep |
DocumentCollaborator | Per-person grants: view, comment, edit, admin |
DocumentCodeLink | The attachment to a repository path, and its sync mode |
DocumentComment | Threads, anchored to a passage |
DocumentYjsState | The server's copy of the CRDT — see Collaboration |
DocumentEmbedding | Chunk embeddings for semantic search, Vector(1024) |
PublishedDocument | The public snapshot; deliberately not a live mirror |
DocumentImportJob | An in-flight migration, including the source_id → document_id map that makes it resumable |
DocumentAuditEvent / DocumentView (models/document_audit.py) | The append-only trail, and per-day read counters |
Access#
services/document_access.py is the single answer, and every read and write
goes through one of its two shapes:
resolve(document, developer)→ anAccessLevelfor one document;visible_clause(workspace, developer)→ a SQL predicate that list, tree, search, favourites, export and the trash compose into their own queries.
A predicate rather than a Python filter, so LIMIT/OFFSET stay correct and a
new listing cannot quietly skip the check. guard_document_route in
api/documents.py is mounted as a router-level dependency, and derives its
floor from the HTTP method: a write needs EDIT unless it appears in
_WRITE_LEVEL_EXCEPTIONS with a stated reason.
Decisions are cached per request on the session (AsyncSession.info), so the
guard and the endpoint do not resolve the same document twice; every mutation
that changes access calls DocumentAccess.invalidate.
Collaboration#
services/document_collaboration.py. The server holds a pycrdt.Doc per open
document, persisted in document_yjs_state, with updates fanned out across
replicas over Redis. Clients speak the standard y-protocol against the
server, not each other.
The room flattens the CRDT back into documents.content on a debounce, through
DocumentService.update_document, so version history, activity and
content_text extraction happen exactly as they do for an ordinary save.
A space that requires approval refuses the socket (close code 4005) and falls
back to single-writer saves, because a debounced CRDT flush would write straight
past the gate.
Frontend#
/frontend/src/app/(app)/docs/ — tree of spaces and pages, rich-text editor,
comment rail, version history, proposal review.
Drive#
File browser with hierarchical folders, previews, and AI metadata.
Routers#
| Router | Purpose |
|---|---|
api/drive.py | File/folder CRUD, move, rename, presigned URLs |
api/file_search.py | Cross-workspace search over file content (uses FileEmbedding) |
Models (models/drive.py)#
DriveFile — file or folder:
| Field | Note |
|---|---|
workspace_id, parent_id | Tree |
name, kind | file / folder / image / video / audio / pdf / doc |
file_url | Reference to the S3 object |
file_size_bytes, content_type | |
uploaded_by_id, created_at, updated_at |
Files are stored in the S3-compatible backend (RustFS in dev; configurable in prod). See file-uploads.md for the upload pattern.
Frontend#
/frontend/src/app/(app)/drive/ — file browser with drag-drop upload, breadcrumbs, previews, and bulk operations.
FileMetadata: the polymorphic spine#
backend/src/aexy/models/file_metadata.py is where the three modules converge.
FileMetadata
├── source_type # drive_file | task_attachment | compliance_document | crm_record_attachment | recording
├── source_id # FK to the source-specific row
├── ai_summary, ai_tags
├── extracted_text
└── FileEmbedding[] # chunk-level embeddings, Vector(1024)
The polymorphism (source_type + source_id instead of one row per source kind) is what lets the AI metadata pipeline treat all binary content uniformly. A PDF in Drive, an attachment on a sprint task, and a compliance document all run through the same code path.
FileEmbedding#
| Field | Note |
|---|---|
metadata_id, chunk_index | Identity (unique together) |
chunk_text | The text chunk |
embedding (Vector(1024) via pgvector) | The vector |
embedding_model | text-embedding-3-large@1024 (OpenRouter) or bge-m3 (Ollama) |
Both models output 1024-dimensional vectors — chosen for cost/quality balance and so the column can be a single vector type.
AI metadata pipeline#
services/file_ai_pipeline.py:run_pipeline(db, source_type, source_id, gateway) is the entry point. Steps:
- Locate the binary in S3 via
FileMetadata.file_url. - Extract text — PDFs via
pypdf, DOCX viapython-docx, video viaffmpegkeyframes + Whisper, images via vision model. - Chunk the extracted text.
- Embed each chunk and write to
FileEmbedding. - Summarize with the LLM gateway; write
ai_summary,ai_tagsback toFileMetadata. - Index for knowledge graph (next section).
Dispatched as the Temporal activity extract_file_ai_metadata (LLM retry, 30-minute timeout, 5-minute heartbeats — dispatch.py:60-68). Workspace-wide backfill is the separate activity backfill_workspace_file_metadata (6-hour timeout, throttled to ~10 files/min/workspace).
The legacy drive_ai_pipeline.py is a deprecated shim that delegates to the polymorphic version.
Knowledge Graph#
Entities and relationships extracted from documents and surfaced for querying.
Router#
api/knowledge_graph.py — Enterprise-gated (require_enterprise_workspace, line 48-80).
Models (models/knowledge_graph.py)#
KnowledgeEntity:
| Field | Note |
|---|---|
entity_type (KnowledgeEntityType) | PERSON / CONCEPT / TECHNOLOGY / PROJECT / ORGANIZATION / CODE / EXTERNAL |
name, description | |
attributes (JSONB) | Type-specific attributes |
confidence | Extraction confidence |
source_document_ids (JSONB) | Where this entity was found |
KnowledgeRelationship:
| Field | Note |
|---|---|
source_entity_id, target_entity_id | |
relationship_type (KnowledgeRelationType) | MENTIONS / RELATED_TO / DEPENDS_ON / AUTHORED_BY / IMPLEMENTS / REFERENCES / LINKS_TO / SHARES_ENTITY |
confidence | |
evidence_excerpts (JSONB) | Snippets from source docs |
KnowledgeDocumentRelationship — joins entities back to their source docs for "show me where this came from" UX.
Service#
services/knowledge_graph_service.py:
GraphFilters—entity_types[],relationship_types[],space_ids[], date range,min_confidence,max_nodesGraphNode,GraphEdge— serialization shapes for the frontend visualizerquery_graph(filters)— runs the filter against the entity/relationship tables and returns a connected subgraph
Extraction is dispatched as the extract_knowledge_from_document Temporal activity (LLM retry, 30-minute timeout). Triggered when a document is created/updated; pulls in entities and relationships incrementally.
Workspace-wide rebuild is rebuild_workspace_graph (LLM retry, 2-hour timeout, 5-minute heartbeats) — re-extracts from every doc, used for major model upgrades.
MCP (Model Context Protocol)#
Aexy is an MCP host — it exposes its internal APIs as MCP tools so external LLMs (Claude Desktop, agentic frontends) can use them.
What's exposed#
From frontend/src/app/(app)/mcp/page.tsx, the registered tool namespaces include:
| Category | Tools |
|---|---|
| Sprint management | aexy_sprints, aexy_sprint_tasks, aexy_sprint_analytics, aexy_projects, aexy_epics, aexy_bugs |
| CRM | aexy_crm_objects, aexy_crm_records, aexy_crm_automations |
| AI agents | aexy_agents, aexy_agent_policies, aexy_workflows |
| Email & GTM | aexy_email_campaigns, aexy_email_infrastructure, aexy_gtm_leads, aexy_gtm_sequences |
| Analytics | aexy_analytics, aexy_developer_insights, aexy_compliance, aexy_assessments |
| Platform | aexy_workspaces, aexy_notifications, aexy_documents |
Authentication#
MCP clients authenticate with aexy_… API tokens (see authentication.md). The workspace scope is set per-token.
Direction#
Aexy is an MCP host/server — it exposes tools. It does not currently consume external MCP servers (no inbound MCP client wired up). If your agent needs to call an external service, integrate it as a tool inside the agent rather than as an MCP consumer.
Frontend#
/frontend/src/app/(app)/mcp/page.tsx — read-only tool registry documentation. Tool definitions live on the backend; the UI surfaces them so admins can see what an external LLM can do with an API token from this workspace.
Frontend summary#
| Route | Purpose |
|---|---|
/documents | Document spaces + editor |
/drive | File browser |
/mcp | MCP tool registry |
| Knowledge graph UI | Surfaced inside /documents/{space}/graph (the workspace-level view of entities derived from that space) |
Common pitfalls#
FileMetadata≠ the source row. Soft-deletingFileMetadatadoesn't delete theDriveFile(or task attachment, etc.) — and vice versa. Cascade carefully.- Re-embedding every save is expensive. Use stable
workflow_ids likefile-meta-{file_id}-{content_hash}so an unchanged file doesn't re-pay extraction cost. The pipeline checks chunk hashes, but only after re-fetching the binary. - Knowledge graph is Enterprise-gated. Non-Enterprise workspaces get 403s on the router. Don't expose links to the graph view in shared UI without an Enterprise feature check.
- Public docs leak embedded references. A public
Documentwith embeddedcrm.record:{id}blocks will render the reference; the public reader can see the linked record'sdisplay_name. Either resolve to a hard string at publish time or scrub embeds for public docs. - MCP tokens don't honor workspace switching. A token created for workspace A only reaches workspace A. Switching the user's current workspace in the UI doesn't broaden the token.
- Backfill at scale:
backfill_workspace_file_metadatais throttled. Don't kick it off and expect immediate results for big workspaces — it can take many hours.