MemQL is the query and mutation language that powers the memory engine. It provides a deterministic, append-only interface for reading and writing concept-backed data stored in TimescaleDB. This document is the canonical reference for MemQL behavior. Whenever the query language changes or new capabilities ship, update this guide alongside the code change.
Concept schemas live under versioned directories. MemQL automatically discovers and loads all version directories (v1, v2, etc.):
text
concepts/
├── v1/
│ ├── myapp/
│ │ └── user/ → v1:myapp:user
│ └── examples/
│ └── game/
│ └── player/ → v1:examples:game:player
└── v2/
└── myapp/
└── profile/ → v2:myapp:profile
All concepts from all version directories are loaded and merged. The version is part of the concept name, so v1:myapp:user and v2:myapp:user are distinct concepts.
Specs are concept-agnostic and shared across all versions:
specs/v1/... contains all spec files.
Specs can be organized into subdirectories for convenience.
This returns all active worlds. MemQL responses use omission semantics—fields are only present when they contain data (see Response Envelope):
text
{
"result": {
"bundle": {
"nodes": [
{
"id": "v1:examples:world:world-aurora",
"concept": "v1:examples:world",
"payload": {
"title": "Aurora Grid",
"status": "active"
}
}
],
"edges": [
{
"type": "contains",
"fromId": "v1:examples:world:world-aurora",
"toId": "v1:examples:module:module-foundations",
"depth": 1
}
],
"rootIds": ["v1:examples:world:world-aurora"]
}
}
}
result.bundle.nodes is a flat slice of every memory node touched during evaluation (matching records + relationship expansions).
result.bundle.edges describes the relationships that were traversed. Edge types include child, contains, aliases, createdBy, interactions, and owns. Omitted when no edges exist.
result.bundle.rootIds captures the IDs that directly satisfied the query before relationship expansion.
result.data mirrors the caller-provided shape() template (see below). Omitted when no shape directive is used; when shaped, contains one element per root.
errors is omitted on success; on failure, contains an array of structured issues (code, message, optional metadata).
result.bundle – Contains the graph structure (nodes, edges, rootIds). For regular queries, the bundle is present with matched nodes. When using shape(), the bundle is omitted—use shapeWithBundle() to include it.
result.bundle.nodes – Array of matched memory nodes. Omitted when empty.
result.bundle.edges – Array of relationship edges. Omitted when no edges exist.
result.bundle.rootIds – Array of root node IDs. Omitted when empty.
result.data – Array of shaped payloads from shape() or shapeWithBundle(). Omitted when no shape directive is used.
errors – Array of error objects when failures occur. Omitted on success.
Consumers should check for the presence of errors before operating on the result. This keeps backend services, clients, and SI agents aligned on the same contract.
Concept names mirror the versioned directory path under concepts/: every segment becomes a colon-delimited token (for example v1/assistant → v1:assistant). Each segment must be a single lowercase alphanumeric word; invalid names cause the loader to reject the concept.
Each concept directory contains a concept.json metadata file that tells the memory engine how to treat the records it stores. Every field is consumed by MemQL (and now available through concepts()—see below):
Property
Description
description
Human-readable summary of what the concept stores. Required so humans and SI systems can reason about the dataset.
type
Node classification: object, collection, or reference. Determines default relationship expectations.
skipDeleted
When true, default queries omit deletion tombstones emitted by delete.json.
defaultFilter
Optional MemQL filter expression automatically applied by higher-level services. Leave empty to return all records.
cacheTTLSeconds
Per-concept cache TTL (seconds). <= 0 disables caching. The global cache honors the smallest TTL among all nodes in the result tree.
relationships
Array describing graph edges. Each entry includes type (e.g. parent, contains, createdBy), field (payload or metadata field used as the pointer), targetConcept, and direction (outgoing, incoming, or bidirectional). The engine now infers whether field points at payload JSON or node metadata—reserved columns (id, createdBy, concept, etc.) may only be referenced at the top level.
The type field in a relationship definition determines how MemQL interprets the graph edge. Valid types:
Type
Description
Use When
parent
This node belongs to a parent node
The field stores a single ID pointing to the parent
contains
This node contains other nodes
The field stores an array of IDs of contained nodes
owns
This node owns other nodes
Similar to contains, but implies exclusive ownership
alias
This node is an alias for another
The field stores the ID of the aliased node
createdBy
This node was created by another
The field stores the creator's ID (can use metadata)
interactsWith
This node interacts with another
Generic association between nodes
Common Mistake: Confusing parent vs child
When a concept has a field that points TO another concept (like spaceId pointing to a space), use "type": "parent". The relationship type describes the direction from the current node's perspective.
Correct - Session belongs to Space (parent relationship):
json
// session/concept.json
{
"relationships": [
{
"type": "parent",
"field": "spaceId",
"targetConcept": "v1:cognition:space",
"direction": "outgoing"
}
]
}
Incorrect - Using "child" when the field points outward:
json
// session/concept.json - WRONG
{
"relationships": [
{
"type": "child",
"field": "spaceId",
"targetConcept": "v1:cognition:space",
"direction": "outgoing"
}
]
}
// Error: relationship type "child" is invalid
Rule of thumb:
If concept A has a field storing concept B's ID → A uses "type": "parent" pointing to B
If concept A has an array of concept B IDs → A uses "type": "contains" pointing to B
The "child" type is not directly used; child relationships are inferred by querying childOf() which finds nodes that have a parent relationship to the target
The variable concept stores configuration values that can be referenced at runtime by workflows and automations. Variables allow configuration to be stored in the database without changing workflow definitions or injecting process environment variables.
Location:
concepts/v1/memql/variable/
Schema:
Field
Type
Required
Description
name
string
Yes
Variable name (uppercase with underscores, e.g., DISCORD_WEBHOOK_URL)
value
string
Yes
The variable value (URL, API key, token, etc.)
description
string
No
Human-readable description
category
enum
No
One of: webhook, api, credential, config, other
sensitive
boolean
No
Whether this contains sensitive data (default: true)
Browser clients connect to /memql/ws, which upgrades to a long-lived WebSocket and forwards frames to the MemqlService.Stream gRPC method. Auth cookies/JWTs are reused automatically because the upgrade happens after the standard middleware stack.
Frames are JSON encodings of the existing protobuf envelopes. A typical request/response pair looks like:
The bridge enforces a small per-connection window (four concurrent queries and a 5 MiB frame limit). Clients should reuse a single socket and listen for queryResult.done or queryError payloads.
Configuration variables (prefixed with MEMQL_WS_) let you tune the gateway:
Variable
Description
Default
MEMQL_WS_DIAL_TIMEOUT_MS
How long to wait when dialing the internal gRPC server.
5000
MEMQL_WS_WRITE_TIMEOUT_MS
Per-message write deadline applied to the WebSocket.
10000
MEMQL_WS_MAX_CONCURRENT_REQUESTS
Maximum in-flight executeQuery messages per WebSocket.
4
MEMQL_WS_MAX_MESSAGE_BYTES
Maximum accepted frame size from the browser.
5242880 (5 MiB)
MEMQL_WS_PING_INTERVAL_MS
Interval for server-side WebSocket keepalive pings. Prevents idle connection timeouts on edge/proxy infrastructure. Set to 0 to disable.
Named shapes are defined in shapes/v1/<domain>/<shapeName>.memql and registered with the engine at startup. Each concept has one comprehensive shape (e.g., participantFull, agentFull, spaceFull).
MemQL's SI integration is intentionally scoped so that language models can only influence projected output—filters, joins, sorts, and grouping remain deterministic.
MEMQL_SI_CACHE_DEFAULT_ENABLED (true/false) toggles whether si() calls cache results when no explicit TTL is provided.
MEMQL_SI_CACHE_MAX_SECONDS caps any SI cache entry (and doubles as the default TTL when caching is enabled). The engine clamps this to ≤ 300 seconds (5 minutes).
Only string literals are allowed for the template and provider arguments. If the provider override is omitted, the engine uses the template's defaultProvider, then falls back to MEMQL_DEFAULT_PROVIDER. The engine enforces projection-only usage—if si() appears inside filters, joins, sorts, or grouping expressions the query fails with si() cannot be used in filter, join, sort, or group expressions; use it only in projection.
The shape template reference documents si() alongside other helpers (node(), children(), etc.). Because si() ultimately calls a language model, response latency and cost are higher than local projections. For time-critical use cases, set a short cacheTTL (e.g. 30 seconds) and lean on MEMQL_SI_CACHE_DEFAULT_ENABLED=true so repeated queries hit the cache.
Warning: Large language models are expensive and should be treated like accelerator cards in the execution plan. MemQL keeps SI usage safe by only allowing si() inside projections (select, shape, or spec outputs that are themselves projected). Filters, joins, sorts, pagination, and mutations remain 100 % deterministic. Before wiring any of the patterns below into prod, confirm that the surrounding specs already catch abuse cases and that operators understand where the SI spend occurs.
At a high level each pattern pairs:
Deterministic queries (filters, specs, sorts) that gather context
An SI template for optional narrative or classification output
The result is a "smart logic engine" that can reason over fresh time-series data while leaving the database, cache, and relationship traversals deterministic.
This caches results for 60 seconds regardless of the concept's default cacheTTLSeconds.
Important behaviors:
The parser folds @cache(<seconds>) into the canonical concept== comparison (concept@cache(30)==v1:assistant), so different hint values generate different cache keys. Two queries that only differ by @cache(30) vs @cache(0) will never share or overwrite cache entries.
Non-zero @cache() hints can re-enable caching for concepts whose cacheTTLSeconds is 0. The hint value becomes the effective TTL (still clamped by CACHE_MAX_TTL) as long as it stays above zero.
Even though @cache(0) produces a unique cache signature, it still prevents caching. cacheTTLForTree() clamps every concept's TTL (global ceiling → concept default → hint override) and skips writes entirely when the resolved TTL is 0.
Concept-scoped @fields(...) annotations also participate in the cache key through the projection signature. Adjusting those per-concept projections creates unique cache entries, ensuring callers never receive a broader or narrower payload than they requested.
-- Running the same insert again produces the same ID
-- This creates a new version of the same record, not a duplicate
The generated ID is a 64-character hexadecimal SHA256 hash. An optional server-side salt (configured via MEMORY_NODES_ZNASLLC_LAB_CONTENTID_SALT) can be added for deployment isolation.
Note: Explicit id parameters always take precedence over content-addressed derivation.
#Identical Payloads Create Versions, Not New Records
This is a critical design behavior that developers must understand:
When you insert the same payload without an explicit ID, you are creating a new version of an existing record, not a new independent record.
Example scenario:
memql
-- First insert: Creates record with ID derived from payload
insert("v1:cognition:space", payload={"name": "New Space", "active": true})
-- Returns: v1:cognition:space:d681fc9d... (created at 12:18:30)
-- Second insert with SAME payload: Creates new VERSION of same record
insert("v1:cognition:space", payload={"name": "New Space", "active": true})
-- Returns: v1:cognition:space:d681fc9d... (created at 12:28:46)
-- Same ID, newer timestamp - this is a new VERSION, not a new record
-- Query returns only one result (the most recent version)
concept==v1:cognition:space;id=="d681fc9d..."
-- Returns: 1 record with createdAt: 12:28:46
To create truly unique records without explicit IDs, the payload must differ:
Goal
Solution
Create multiple independent records
Use unique values in the payload (different names, UUIDs, etc.)
Update an existing record
Insert with the same payload/ID (this is the intended pattern)
Ensure uniqueness
Pass an explicit id parameter
Creating unique records without explicit IDs:
memql
-- These create DIFFERENT records (different payloads = different IDs)
Queries can be executed via the gRPC MemqlService.Stream bidirectional RPC or through the WebSocket bridge at /memql/ws. Both paths share the same backend validation, so every expression that references si() or inline specs follows the same rules described in this guide.
MemQL provides a real-time event system that delivers notifications for graph mutations, query execution, SI completions, and session lifecycle events. Clients subscribe over the existing bidirectional gRPC stream (or WebSocket bridge) and receive EventNotification messages as changes occur.
These roadmap items are planned but not yet implemented. Update this section as features land or priorities change.
Streaming Responses – add streaming execution so clients can start reading partial MemQL results before the query finishes (instead of waiting for a single HTTP response body).
This is a condensed syntax specification designed to fit within limited context windows. Use this for quick reference; for detailed explanations and examples, see the sections above.
MemQL distinguishes between directive wrappers and expression functions:
Directives wrap an entire expression and peel off before evaluation. They include sort(), paginate(), select(), asOf(), and withDepth(). Only one instance of each directive can wrap a query, and directives must form the outermost stack (for example sort(select(paginate(...)))).
Functions such as parentOf(), contains(), ids(), or concepts() participate directly in the expression tree and can appear anywhere a comparison can. They do not get peeled off by the parser.
Keep this separation in mind when composing helpers: wrap the final expression with directives in whatever order you need (usually select() closest to the expression, followed by paginate()/sort()), and place relationship functions inside that stack.
Graph bundles are easy to inspect programmatically, but sometimes you want the engine to return a custom JSON shape. The shape(<expr>, <template>) directive lets you describe the output structure in-line with the query.
The template syntax looks like JSON and supports nested objects/arrays.
Use helper functions inside the template to reference the current node or related nodes:
node("field", "payload.path.*") – pulls metadata/payload fields from the current node. With no arguments the entire node is returned.
children(<template>), aliases(<template>), contains(<template>), owns(<template>), createdBy(<template>), interactions(<template>) – traverse edges of that type and apply the nested template to every match. Note: Relationship pointer fields are optional—if a node has a null or missing pointer field, it is silently skipped during traversal rather than causing an error.
json(<template>) – serializes the inner template result to a JSON string. Essential for embedding structured data in SI prompt templates. See JSON Serialization with json() for details.
match(case(...), default(...)) – conditional value selection based on specs or inline comparisons. See Conditional Logic with match() for details.
si("templateId", {data}, "provider", ttl) – invoke an SI template for dynamic content generation. See SI providers, prompts, and si() for details.
Single-field projection optimization: When node() extracts exactly one field, the value is returned directly rather than wrapped in a map. This produces cleaner JSON output:
This optimization applies to all contexts where node() is used, including inside relationship helpers like children() and createdBy().
Functions NOT allowed in shape templates:select(), paginate(), sort(), asOf(), withDepth(), parentOf(), childOf(), ids(), and concepts() are directive or expression functions that operate at the query level, not inside templates. To include related data in a shape template, use the relationship helpers listed above (contains(), children(), etc.) instead of trying to run sub-queries.
When multiple root nodes match the query, shape() populates result.data with an array of template results. With a single root, the template result is still wrapped in a one-element array so consumers never need to branch on type.
shape() omits the bundle by default. The response contains only result.data—no result.bundle. This reduces payload size when you only need the transformed data.
Both shape() and shapeWithBundle()require a template argument. Calling either without a template results in an error.
When you need both the shaped data and the underlying graph
Use shapeWithBundle() when you need access to the raw nodes, edges, and relationships alongside the transformed output—for example, when building UIs that display both a summary view and a detailed graph visualization.
The result collapses into a single JSON object containing the parent world, its modules, and the quests within each module—no client-side traversal required.
With shape(), the response contains only result.data—the bundle is omitted. If you need both the shaped data and the underlying graph, use shapeWithBundle() instead, which includes result.bundle alongside result.data.
MemQL's SI integration is intentionally scoped so that language models can only influence projected output—filters, joins, sorts, and grouping remain deterministic.
Providers live in dsl/providers/providers.memql. Each is declared in struct form (provider NAME { ... }); the legacy func (Provider) receiver form is retired and rejected at parse time. Every provider specifies a name, @type attribute, @model attribute, auth block, and optional params block. The first provider with @default becomes the fallback (unless MEMQL_DEFAULT_PROVIDER is set). Example:
memql
@extends("openai")
@model("gpt-5.4-mini")
provider chat54Mini {
params {
maxCompletionTokens 16384
}
}
Lifecycle (@enabled / @disabled). Providers accept the lifecycle
flags. @enabled is the explicit-on default (no-op); @disabled skips
the provider at load -- not registered, no auth resolution -- so it
emits zero "registered as unavailable" warnings while staying in the
tree for a future re-enable. @disabled on a @basepropagates to
every child that @extends it, turning the whole vendor lane off:
memql
@disabled
@base
@type("Google")
provider google {
auth { apiKey env("MEMQL_SI_GOOGLE_API_KEY") }
}
Dependents degrade gracefully: a policy whose @primary is disabled
routes via its @fallback; a prompt whose @defaultProvider is
disabled falls back to the default structured provider.
Semantics.@disabled means the construct is not loaded/active
at runtime right now. It does NOT mean deprecated, abandoned, or
exempt from maintenance / refactors / conformance -- it is a reversible
on/off switch (a separate axis from @deprecated). This applies to
every construct that takes the flag (functions / builtins / prompts /
specs / seeds / providers).
Provider types (registered in component/memql/si_providers.go):
Voice-to-voice via the OpenAI Realtime conversation mode was retired
in favour of the Polyphon pipeline (LiveKit + ASR/TTS in
integrations/deepgram/ and integrations/openai/). The
OpenAIRealtime provider type and the corresponding realtime*
configs have been removed; the only Realtime usage today is
transcription-only ASR.
Available text providers (representative; full list in
providers/v1/):
See docs/polyphon-architecture.md for
the voice pipeline.
Prompt templates live in prompts/v1/**/*.memql. Each .memql file defines a single prompt using the func (Prompt) receiver. Prompts include @description, @defaultProvider, @templateFile attributes, and an @input block that declares the expected arguments (replacing the former JSON Schema validation).
SI cache env vars control response reuse:
MEMQL_SI_CACHE_DEFAULT_ENABLED (true/false) toggles whether si() calls cache results when no explicit TTL is provided.
MEMQL_SI_CACHE_MAX_SECONDS caps any SI cache entry (and doubles as the default TTL when caching is enabled). The engine clamps this to ≤ 300 seconds (5 minutes).
Template ID – required string literal matching a prompt file.
Data object – optional JSON-like object literal evaluated with the shape helpers (node(), children(), etc.). When omitted, an empty object is used.
Provider override – optional string literal to force a provider for a single call.
Cache TTL – optional integer literal (seconds) that enables caching for this specific call. The value must be between 0 and MEMQL_SI_CACHE_MAX_SECONDS (inclusive); 0 disables caching even if the global default is on.
Only string literals are allowed for the template and provider arguments. If the provider override is omitted, the engine uses the template's defaultProvider, then falls back to MEMQL_DEFAULT_PROVIDER. The engine enforces projection-only usage—if si() appears inside filters, joins, sorts, or grouping expressions the query fails with si() cannot be used in filter, join, sort, or group expressions; use it only in projection.
The SI cache sits inside the runtime and hashes {templateId, provider, renderedPrompt} as the cache key. When caching is enabled (globally or via the per-call TTL argument), a successful provider response is reused until its TTL expires—preventing duplicate LLM calls for identical prompts. The cache TTL is always clamped to five minutes. Set MEMQL_SI_CACHE_DEFAULT_ENABLED=false to require explicit TTLs on every si() call, or pass 0 as the per-call TTL to skip caching for a specific invocation even when the default is enabled.
The provider response is inserted directly into the shaped output: if the model returns valid JSON it is decoded into native structures, otherwise the raw string is returned.
The json() function serializes a template value to a JSON string. This is essential when embedding structured data in SI prompt templates, where the prompt text expects valid JSON syntax rather than Go's native map format.
The match() function provides conditional value selection inside shape() templates. It evaluates conditions in order and returns the value of the first matching case, enabling deterministic business logic without SI overhead.
Use a named spec as the condition. The spec is evaluated against the current node's payload:
text
shape(
concept==v1:customer,
{
"id": node("id"),
"origin": match(
case(hispanicMiddleName, "hisp"),
case(asianMiddleName, "asian"),
default("unknown")
)
}
)
Specs referenced in match() must be defined in specs/v1/*.json or as inline specs:
json
{
"name": "hispanicMiddleName",
"description": "Middle name appears in Hispanic name list.",
"expression": "payload.middleName in (\"Garcia\",\"Rodriguez\",\"Martinez\",\"Lopez\",\"Hernandez\")"
}
Note: Relationship-based specs (e.g., parentOf(...), childOf(...)) are not supported in match() conditions because they require graph traversal beyond the current node.
The most powerful use of match() combines deterministic specs with SI fallbacks. Known cases are handled instantly without SI cost; unknown cases fall through to an SI classifier:
text
shape(
concept==v1:customer,
{
"id": node("id"),
"middleName": node("payload.middleName"),
"nameOrigin": match(
case(hispanicMiddleName, "Hispanic"),
case(asianMiddleName, "Asian"),
case(europeanMiddleName, "European"),
default(si("nameOriginClassifier.v1", {
"name": node("payload.middleName")
}))
)
}
)
Benefits:
Cost efficiency: Known patterns (Hispanic, Asian, European names) are handled deterministically—no SI call.
Accuracy: Unknown patterns still get intelligent classification via SI.
Transparency: You can see exactly which cases trigger SI spend.
Caching: SI responses can be cached per the TTL rules, further reducing cost.
Warning: Large language models are expensive and should be treated like accelerator cards in the execution plan. MemQL keeps SI usage safe by only allowing si() inside projections (select, shape, or spec outputs that are themselves projected). Filters, joins, sorts, pagination, and mutations remain 100 % deterministic. Before wiring any of the patterns below into prod, confirm that the surrounding specs already catch abuse cases and that operators understand where the SI spend occurs.
At a high level each pattern pairs:
Specs for deterministic triage/flagging
select/shape for assembling structured context
An SI template for optional narrative or classification output
The result is a “smart logic engine” that can reason over fresh time-series data while leaving the database, cache, and relationship traversals deterministic.
Each entry calls out the intended goal, the template ID, a MemQL snippet, and why the approach is useful. Feel free to mix-and-match the building blocks so long as SI remains in the projection layer.
Why it helps: Learners get a cached summary they can review before starting a learning path, without hitting the SI endpoint repeatedly. The contains() helper traverses the world→module relationship and json() serializes the module data for the SI prompt.
Note: Directive functions like select(), paginate(), and sort() are not valid inside shape templates. These are top-level query directives, not shape template helpers. To include related data inside a shape template, use relationship helpers like contains(), children(), owns(), etc. See Invalid Query Examples for more details.
This query disables caching for the root conversations, limits cached messages to five minutes, and leaves users at ten minutes (or whatever is lower between their concept default and CACHE_MAX_TTL).
If any layer resolves to 0, the engine skips caching for the entire query, guaranteeing fresh reads after high-churn writes.
#Advanced: Cache Hints, Cache Keys, and @fields Overlaps
The parser folds @cache(<seconds>) into the canonical concept== comparison (concept@cache(30)==v1:assistant), so different hint values generate different cache keys. Two queries that only differ by @cache(30) vs @cache(0) will never share or overwrite cache entries.
Non-zero @cache() hints can re-enable caching for concepts whose cacheTTLSeconds is 0. The hint value becomes the effective TTL (still clamped by CACHE_MAX_TTL) as long as it stays above zero.
Even though @cache(0) produces a unique cache signature, it still prevents caching. cacheTTLForTree() clamps every concept’s TTL (global ceiling → concept default → hint override) and skips writes entirely when the resolved TTL is 0.
Concept-scoped @fields(...) annotations also participate in the cache key through the projection signature. Adjusting those per-concept projections creates unique cache entries, ensuring callers never receive a broader or narrower payload than they requested.
select(<expr>, "<field>", "<field2>", ...) projects the result payloads down to explicit paths. Fields must be quoted strings that start with either a metadata token ("meta.concept", "meta.*", "id", etc.) or a payload path ("payload.profile.displayName"). The parser enforces valid paths and supports direct-child wildcards with the payload.<object>.* suffix.
The directive can wrap any MemQL expression, sits alongside sort()/paginate(), and may only appear once per query.
Metadata becomes opt-in: id is always present, but concept, type, createdAt, createdBy, and schema are excluded unless you request them explicitly (for example via "meta.concept" or "meta.*"). Bare tokens ("concept", "createdAt") behave the same for convenience.
Nested selections prune maps so only the requested branches survive. For example, select(concept==v1:assistant, "payload.profile.displayName") strips every other payload property but leaves payload.profile.displayName intact.
Wildcards copy every direct child of the specified object: select(..., "payload.profile.*") keeps the full profile object without enumerating its keys, while select(..., "meta.*") restores the entire metadata envelope.
Use "payload" (without a wildcard) when you need the full payload. The payload.* shorthand only works on nested objects (for example payload.profile.*) and is rejected at the root level.
Include "meta.schema" in the field list when you need the embedded JSON Schema document; otherwise it is omitted along with unrequested metadata.
If no payload fields remain after intersection (see @fields below), the engine returns an empty payload object so callers can rely on consistent shapes.
@fields only applies to concept== comparisons with string literals. The engine validates the syntax and errors if the annotation is attached to other fields or operators.
When both global and concept-specific selections exist, the engine intersects them so each concept only returns the overlap. When no global select() exists, the concept-specific list stands on its own.
You can combine multiple annotated concepts inside the same query to shape related records differently.
paginate(<expr>, limit, offset?) constrains result windows. When omitted, the engine uses the defaults (limit = MEMORY_ENGINE_MAX_RESULTS, offset = 0). The function:
Requires at least one integer argument (limit) greater than zero.
Accepts an optional second integer argument for offset.
Can be combined with other helpers (e.g., sort(paginate(...), ...)).
The parser expects directive helpers (such as withDepth(), paginate(), sort(), asOf()) to wrap the entire base expression. Embedding them inside shape() helpers or relationship functions causes syntax errors.
Error: invalid query syntax: unknown shape function "withDepth" – the directive is nested inside owns(...), so the parser treats it as an unknown shape helper.
By wrapping the full expression with withDepth(), the directive is applied before shape() executes, keeping the syntax valid while still limiting traversal depth.
Relationship helpers inside shape() templates must also be limited to the supported list (node, children, contains, owns, aliases, createdBy, interactions, match, ai). Trying to call other helper names (for example parent()) results in the parser treating them as unknown shape functions. To walk up the graph, wrap the base expression with parentOf() (optionally guarded by withDepth()) before applying shape().
If you need to traverse parents of the owned nodes, express that traversal in the base MemQL expression using parentOf(owns(...)) (and then shape the result) rather than nesting unsupported helpers inside the template.
Relationship functions such as interactsWith() must also live in the MemQL expression, not inside the template.
Invalid
text
shape(
concept==v1:examples:mentor;id=="mentor-lira",
{"modules": interactsWith(node("payload.name"))}
)
Error: invalid query syntax: unknown shape function "interactsWith" – the template helper isn’t recognized.
Use parentOf(<expr>) to change the root set to the desired ancestors, then optionally clamp recursion with withDepth(). There is no parents() template helper—relationship traversals must happen in the base MemQL expression before shaping.
The query above returns one shaped object per ancestor (closest parent first) with result.data containing the lineage list. Increase the withDepth() value to walk further up the graph, or pair the expression with shapeWithBundle() when you also need access to the raw nodes/edges for visualization.
Run interactsWith() outside shape() (where relationship functions are supported), then use interactions() inside the template to traverse from the nodes returned by that expression.
Insert statements have a similar constraint: the id argument must be a string literal (or omitted so the engine generates one). Calling helper functions there (for example uuid()) triggers a syntax error because the parser is expecting the opening quote for a string.
Shape template objects require quoted string keys and node() function calls for field access. Using bare identifiers (JavaScript-style object shorthand) triggers a syntax error.
Error: invalid query syntax: shape template object keys must be strings – bare identifiers like id: are not recognized; the parser expects quoted keys.
Both the keys ("id", "title", "status") and the field references (node("id"), node("payload.title")) must follow the correct syntax. This applies to all shape template objects, including nested templates inside children(), owns(), and si() data arguments.
MemQL now exposes the documentation and concept catalog directly through the expression language so clients (human or SI) can bootstrap themselves dynamically.
These introspection calls are builtins declared in functions/v1/builtin/*.json. Their names, aliases, and argument contracts are loaded into the function registry at startup; the parser and executor route them through registry metadata rather than hardcoded name branches.
Lists the concepts (and their schemas) available in the current deployment. An optional pattern argument filters concepts by name (case-insensitive substring match).
text
// List all concepts
concepts()
// Filter concepts by pattern (e.g., all CRM-related concepts)
concepts("crm")
// Combine with paginate() to page through long lists
Because the result set is synthetic, wrap the call with paginate() whenever you expect many concepts.
Directive helpers such as paginate(), sort(), asOf(), and withDepth() must be the outermost wrapper around the query expression. Wrap any other functions (like ids() or relationship traversals) inside these directives so the parser can peel them off cleanly.
Predicts the content-addressed ID that would be generated for a concept+payload combination, without actually inserting the data. Uses the same SHA256 algorithm as insert():
Performs complete preflight validation without inserting: validates payload against schema, predicts the content ID, and checks if a record with that ID already exists:
MemQL supports append-only inserts via the insert() function:
text
insert(
"v1:examples:world",
id="world-nebula",
payload={
"title":"Nebula Grid",
"slug":"nebula-grid",
"status":"active",
"difficultyCurve":"advanced"
}
)
Rules:
One insert() per statement; no mixing reads and writes.
Payload must match the concept schema (validated automatically).
Relationship hints (parent, aliasOf) rewrite the payload before persistence.
Inserts return the created node inside result.bundle (single node, empty edge list, and rootIds containing the inserted ID). result.data stays [] unless you wrap the mutation with shape().
Stored identifiers always take the form <concept>:<id>; providing a bare id argument automatically applies the prefix.
When the id argument is omitted, the engine auto-generates a UUID v4 (and still prefixes it with the concept name) before returning the created node so callers can persist the assigned identifier.
MemQL is built on TimescaleDB and follows an append-only, immutable data model. There is no update() mutation by design. Instead, to change a record's state:
Insert a new version with the same ID but updated payload fields
Query to retrieve the most recent version of each record (queries always return current state)
Full history is preserved and queryable via asOf()
Complete history of all changes with timestamps and actors
Time travel
Query data as it existed at any point: asOf(expr, "2025-01-01T00:00:00Z")
No data loss
Records are never destroyed; "deletes" are soft (set active: false)
Determinism
Same query + same timestamp = identical results, always
This pattern is fundamental to MemQL. When building automations, functions, or any data workflows, always think "insert new version" rather than "update existing record."
Specifications (specs) are named boolean predicates that can be embedded anywhere a regular filter expression is allowed. Specs are concept-agnostic—they reference only payload fields and relationship functions, never specific concepts. This makes specs universally shareable: the same spec works for any concept that has the required fields.
When both .memql and .json files exist, the .memql file takes priority. Names must be camelCase (for example hasEmail). At startup the engine parses every file, validates the expression syntax, and rejects duplicates.
Global specs can reference other global specs; the loader resolves these dependencies, detects cycles, and pre-expands the final expression that the executor uses at runtime.
Specs can include relationship functions to create powerful reusable predicates. The relationship resolution uses the concept's relationship definitions at runtime, so these specs remain concept-agnostic:
json
{
"name": "hasActiveParent",
"description": "Node's parent has status active.",
Inline specs can reference previously declared inline specs or any global spec. Their names must also be camelCase and they cannot shadow global specs. Unlike global specs, inline specs can include concept constraints since they are defined in the context of a specific query.
Must evaluate to a boolean expression (logical operators, comparisons, relationship functions, or nested specs).
Track a reserved UsesSI flag whenever si() appears inside the spec expression. Specs flagged in this way cannot be used in filter expressions yet—the engine raises Spec '<name>' uses si() and cannot be used in filter expressions. until projection-safe spec contexts are supported.
Global specs only:
Must not constrain concept==—they are concept-agnostic by design.
Payload path validation happens at query time when the spec is applied to a specific concept, not at load time.
During parsing the engine resolves every spec reference into the underlying expression tree, so the resulting query plan behaves exactly as if the spec contents were written inline.
MemQL is a declarative query language with no concept of variables or mutable state. Specs are named predicates (rules that evaluate to true/false against nodes), not variables that store values.
result := someOtherVariable -- No variable references
Valid — specs define predicates (rules):
text
isActive := payload.active==true -- "node's active field equals true"
hasHighScore := payload.score>90 -- "node's score field is greater than 90"
combined := isActive ; hasHighScore -- Predicate composition with AND
Specs answer the question "does this node match?" — they don't hold values. This design keeps MemQL fully deterministic: every query produces the same result given the same data, with no hidden state.
Functions are named, reusable MemQL queries that accept an optional JSON object argument. Unlike specs (which are boolean predicates for filtering), functions encapsulate complete queries with parameter-based filtering.
args { ... } block — Inline argument schema defining function arguments.
For struct-form queries / mutations, the block lives inside the body;
for procedural functions it sits at file-top above the func (...)
declaration:
memql
args {
spaceId string
status string@enum("active", "idle", "left")
participantType string@enum("human", "si")
}
Annotations: @required (non-optional), @enum("a", "b", ...)
(value set), @default(<expr>) (default when caller omits the
field), @description("...").
Comments in function .memql files start with -- (double dash) and are extracted as the function's description.
Filenames have NO prefix; the directory (queries/, mutations/)
names the kind. The function declaration inside DOES carry the
query / mutation prefix (queryActiveSpaces, mutationCreateSpace).
The loader derives the expected name and rejects mismatches at startup.
Function args are declared via an args { ... } block — body
sub-block in struct form, file-top block in procedural form.
Files starting with _ are skipped (use for documentation)
Circular dependencies are detected and rejected
Functions are loaded after specs (so they can reference specs)
Mutations follow the same shell with an insert { ... } or
update { id: ..., ... } block in place of filter / shape.
The concept binding lives in the signature (query <Concept> <name>
/ mutation <Concept> <name>); cross-file dependencies come in via
file-top use <module>.{ ... } imports. The legacy @useConcept
annotation family is retired and rejected at parse time.
The rewriter still emits a func (Receiver) NAME(ctx any) (any, error) { ctx.output = ...; return ctx, nil } shape for the engine
parser, with args.X source-rewritten to ctx.X. Don't author
that form. Every receiver kind has a struct form -- queries
above, mutations next to them, logic with body { ... ; return <expr> }, automations as step lists. The (ctx any) parameter
and ctx.output = boilerplate were retired in memql#302 / #303;
the canonical form returns the value directly.
Functions can be triggered as steps within workflows:
json
{
"id": "processStale",
"name": "Process Stale Conversations",
"type": "function",
"function": {
"name": "staleConversations"
}
}
See workflows/v1/_workflowSchemaReference.jsonc for full workflow documentation, including multi-step pipelines, conditional branching, forEach iteration, and parallel execution.
Automations have access to special context expressions that are only valid within automation definitions. These expressions cannot be used in regular queries or functions.
Expression
Context
Description
event()
Event-triggered automations
References the triggering event payload
input()
Step execution
References the input passed to a step
item()
forEach loops
References the current item being processed
index()
forEach loops
References the current iteration index
step("stepId")
After step execution
References the result of a previous step
error()
onError handlers
References the current error in error handlers
error("message")
Control flow
Throws an error with the specified message
Important: These expressions are resolved at automation runtime via $variable string substitution (e.g., $event.payload.userId, $error). They cannot be evaluated in query context and will return an error if attempted.
The @filter annotation provides a standalone way to attach a filter predicate to an automation, as an alternative to embedding the filter directly in @trigger. This is useful when the filter expression is complex or when you want to separate trigger definition from filtering logic:
MemQL provides several building blocks for composing queries:
Concept paths -- Insert concept==v1:... paths using the versioned concept hierarchy under concepts/.
Global specs -- Reference specs from specs/v1/*.json in filter expressions. Inline spec declarations are also supported.
Relationship helpers -- Wire contains(), owns(), parentOf(), etc. between two concept IDs to produce ready-to-run filter fragments.
SI assist -- Build si(“templateId”, {...}, “provider”, ttl) calls using prompt templates from prompts/v1/**/*.memql and providers from providers/v1/**/*.memql. Cache TTL is clamped to MEMQL_SI_CACHE_MAX_SECONDS (currently 300s). The projection-only rules described above apply.
All query composition ultimately executes through the standard gRPC or WebSocket path, so every expression follows the same backend validation described in this guide.
MemQL provides a real-time event system that delivers notifications for graph mutations, query execution, SI completions, and session lifecycle events. Clients subscribe over the existing bidirectional gRPC stream (or WebSocket bridge) and receive EventNotification messages as changes occur.
These roadmap items are planned but not yet implemented. Update this section as features land or priorities change.
Streaming Responses – add streaming execution so clients can start reading partial MemQL results before the query finishes (instead of waiting for a single HTTP response body).
The runtime grammar consumed by engine.Execute(ctx, query string) --
function invocations (funcName({k: v, ...})), filter expressions
(concept==X; payload.Y==Z), accessor references (actor.X,
args.X) -- is historically parsed by component/memql/parser.go,
which lives alongside the load-time parser in
component/language/parser. These two parsers were the duplication
behind memql#216 / #221 / #239, and the broader retirement is
tracked under epic #218 across three sequenced slices: #248 (add
opt-in path), #249 (flip default after soak), #250 (delete the old
parser).
For the soak window, the langparser-backed runtime path is
opt-in via:
go
engine.UseLangparserRuntime(true) // flag default is OFF
When ON, engine.Execute(ctx, string) routes the query through
langparser.ParseExpression + ASTConverter for the shapes it
covers (every shape SDK-generated builders produce + the
concept==X hand-written form). Two shapes still fall back to the
old parser:
Timestamp suffix -- concept==X @latest /
concept==X @"2026-01-01T00:00:00Z". Handled post-parse on the
memql path; the langparser path rejects it upfront via a
sentinel so the fall-back is transparent.
Inline spec definitions -- name := expr. Same.
Behavior is byte-identical for every supported shape -- guarded
by the cross-parser equivalence test
TestParseViaLangparser_Equivalence in
component/memql/parser_langpath_test.go. Add a row there if a
new caller adopts a shape the corpus doesn't cover.
The flag flips to ON-by-default in #249 after one to two release
cycles of dev/staging use with no parser-related regressions.