Architecture
Technical detail for the operator or engineer implementing a space inside Hue. For the audience-facing overview, read What is Hue.
Tenancy
- Per-Space private Postgres schema. Each Space sits in its own schema; every table (threads, tags, edges, context_store, audit_log) exists once per schema. No
WHERE workspace_id = ?filters — physical isolation. - Tenant boundary is declarative.
workspaces.tenantBoundaryAtflips a Space into its own schema. Setting the field provisions the schema and migrates every descendant's rows into it; unsetting reverses. Seesrc/core/tenant.ts::getTenantRootId.
Edges — the knowledge-graph core
The edges table is where every relationship lands. One row is (subjectType, subjectId) --[predicate]--> (objectType, objectId) with optional properties, displayText, embedding, sourceSkill, createdBy, createdAt. Schema at src/core/schema/edges.ts.
Indexes
Three composite btree indexes scoped by workspace:
edges_subject_idx (workspace_id, subject_type, subject_id, predicate)— one-hop forward.edges_object_idx (workspace_id, object_type, object_id, predicate)— one-hop reverse.edges_predicate_idx (workspace_id, predicate)— predicate-only enumeration.
Plus a 6-tuple edges_unique_tuple unique index for idempotent re-emission, and an edges_embedding_hnsw_idx HNSW cosine index for the semantic layer.
Walks
graph.walk runs typed BFS in application code, issuing one SELECT per hop against the composite index. Up to 6 hops, bounded by maxNodes. Complexity is O(edges-touched × log n) on an HNSW-covered edges table.
Verified on synthetic fixtures to low millions of edges per tenant. Sub-second two-hop walks at 100M edges are projected from HNSW and btree recall / latency benchmarks published by the pgvector maintainers (single-node Postgres, SSD-backed, standard cloud instance sizes). Real tenants past ~10M are the first benchmark opportunity; this page will carry measured numbers once that's available.
Partitioning
When a single tenant approaches ~100M edges, declarative Postgres partitioning on workspace_id splits edges into per-partition tables. Composite indexes and the HNSW index attach per-partition. Zero application-side change — the Drizzle layer keeps the same from(edges) calls; the planner chooses the right partition based on the workspace_id predicate.
Semantic layer
Alongside exact edges, pgvector provides cosine-similarity search via:
embedding vector(1536)column onedges, nullable.- HNSW index with
vector_cosine_opsfor log-N top-k. display_text textcolumn holding the natural-language description the vector was computed from (so callers see what was embedded, not just a similarity score).llm.embedskill — OpenAItext-embedding-3-smallfacade, 1536-dim, cosine-normalised. Swap-the-file to change providers globally.graph.searchSemantic— cosine top-k with optional predicate filter. Returns edge tuples + similarity score; never source-service payloads.graph.reembedPending— batch worker that drainsedges WHERE embedding IS NULL AND display_text IS NOT NULL.
Only Hue-native displayText is embedded. Source-service payloads (Gmail message bodies, CRM record fields, etc.) are never vectorised — the graph is a federated pointer map, not a warehouse.
Surfaces
Every defineSkill() auto-exposes on three surfaces simultaneously:
- REST.
POST /api/rest/{plugin}/{fn}with JSON body. - MCP. JSON-RPC 2.0 tool list + tool call at
/api/mcp. - OpenAPI. Auto-generated spec at
/api/openapi.
Same auth, same rate limits, same audit log regardless of surface.
Skill execution pipeline (executeSkill)
In order, for every call:
- Load the skill from the registry; 404 if unknown.
- Permission check via
checkPermission(userId, workspaceId, service, skill, action)unless classification ispublic. - Input validation against the skill's Zod schema.
isServiceEnabled(plugin, spaceId)— SERVICE_DISABLED if the space has toggled this service off.- Scope substitution — for each
scope.fields[], replace the input field with the space's pinned value unless the caller holdsscope.setaccess. - Idempotency gate — if the skill is
idempotent: trueand the caller presentedIdempotency-Key, look up the hash; return cached response if hit. - PII gate — if the space has PII redaction on, scrub input strings.
- Input filters chain.
- Handler — wall-clock budget timer if declared;
ctx.spendtracks cost. - Output filters chain.
- Fires declared hooks.
- Output validation against the Zod output schema.
- Billing cost computation (
billing.costUsd). - Audit log row (hash-chained, redacted per classification).
- Lifecycle end hook.
- Report-card push into context store if the skill declares one.
- PII redaction on output.
- Return.
The three layers
Architecture is best understood as a three-layer mind:
CORTEX = context lake → "what matters in this space" (soft recommendations)
GRAPH = edges → walkable ref-to-ref relationships (user-authored only)
SERVICES = connectors → ground truth, re-fetched on demand
An LLM session on a Space reads the cortex first (spaces.listPinned) to orient itself, walks the graph when it needs to trace a connection (graph.walk / graph.neighbors / graph.searchSemantic), and hits a service (<ref>.fetchSkill) when it needs live data. Meaning is derived on demand, never warehoused.
Primitives and their storage
| Primitive | Where | Cascade | Notes |
|---|---|---|---|
| Access | workspace_access + permission_overrides | No | Explicit per-(space, human). |
| Context | context_store(namespace, key, workspaceId, userId) | Yes (closest-wins) | SASS-like. |
| Connectors | connector_auth(provider, workspaceId) | Yes | Walks up parent chain. |
| Context lake (pins) | context_store(namespace='pinned', key='references', workspaceId) | Yes (concat-array, dedupe-by-newest) | SOFT default. Cortex layer. List skills read pins as soft filters. |
| Scope | context_store(namespace=<plugin>, key='_scope', workspaceId) | Yes | HARD lock. Executor substitutes on skill input; throws OUT_OF_SCOPE. |
| Service enablement | context_store(namespace=services, key=<plugin>, workspaceId) | Yes | Default = enabled. |
| Audit | audit_log (hash-chained) | No | Append-only. |
| Edges | edges | No (per-space) | Per-tenant schema scales partitions per-tenant. User-authored ref-to-ref only; pins do NOT emit edges. |
| Embeddings | edges.embedding | No | HNSW cosine index. |
Lake vs scope — the env-var analogy. Both persist to context_store; their semantics are opposite:
- Context lake = DEFAULT value of an env var. Skill reads it when the caller didn't pass one. Caller can always override per-call. The LLM reads pins as "recommendations."
- Scope = LOCKED value. Framework executor substitutes on every call; throws if a non-admin caller tries to reach outside. The LLM reads scope as a "boundary."
Writing a new service
plugins/<name>/folder.- One file per skill in
skills/. Each skill wrapsdefineSkill(). index.tsregisters the plugin.manifest.jsonis auto-generated bynpm run manifests:sync.- Tests in
tests/. - Optional:
ui/index.tsxDashboardPanel if the service has a Space tab. - Optional:
oauth: { ... }block on register if the service is a connector. - Optional:
scopeSchemafor scope-substituted inputs. - Optional: emit edges from handlers by calling
graph.addEdgedirectly, OR wire a listener inplugins/graph/hooks.tson your service's fired hook.
Deploy with ./scripts/safe-build.sh. Four drift guards (manifests:check, describe:check, schema:diff, plugin:lint) block on any inconsistency.
See also
- Build Guides — step-by-step for wiring a new service.
- Service Registry — live list of connectable services.
- Security Overview — threat model, compliance mappings.
- What is Hue — the audience-facing overview.