docsLanguage

MemQL Function Language Specification

Status: Stable Purpose: Specification for the function-like DSL constructs in MemQL


Overview

MemQL functions provide reusable, parameterized operations. Every construct is declared in struct form -- an annotated block with a keyword header -- and lives in a per-namespace, per-construct file (dsl/<namespace>/<construct>s.memql, e.g. dsl/library/queries.memql).

ConstructPurpose
QueryRead rows of a concept with a filter and a shape projection
MutationWrite one row (insert or update) of a concept
LogicA pure decision: statements that end in return, called from an automation or another logic
AutomationEvent- or schedule-triggered workflow
PromptAI prompt template with a typed input schema
ProviderAI vendor + model configuration
ShapeReusable field-projection template
ToolAI-callable surface over a query / mutation / builtin
BuiltinGo-backed operation behind a declarative schema

Retired: receiver-function constructs. The legacy func (Query|Mutation|Spec|Tool|Prompt|Provider|Builtin|Automation|Shape|Policy) ... receiver syntax is retired and rejected at parse time with a migration hint. The struct forms documented below are the only accepted author surface.

Naming Convention

Construct names carry no kind prefix (memql#2853). Name a construct for what it does; the keyword already states what it is.

  • Query: what it returns (e.g. activeFolders, userById)
  • Mutation: the verb (e.g. createFolder, archiveUser)
  • Logic: the verb (e.g. bootstrapSession, generateResponse)
  • Spec / trait: the predicate (e.g. isActiveRecord, requiresOwner)
  • Prompt: descriptive name (e.g. agentReply, consolidateMemory)
  • Provider: provider name (e.g. chat54Mini, streamClaudeSonnet)
  • Shape: <concept><Projection> (e.g. folderCard, artifactFull)

Gated by TestNoKindPrefixInConstructNames in test/dslconformance/naming_conventions_test.go. Full rationale and history: naming-conventions.md.

This page previously mandated kind prefixes and claimed the compiler emitted naming diagnostics for mismatches. Both were false: 0 of 1091 shipped declarations carried a prefix, and the naming lint was retired in epic #2031 -- TestCompileSource_NoNamingWarnings now fails the build if any naming.* diagnostic is emitted at all.

(Deliberately paraphrased rather than quoted: TestNamingDocsDoNotMandateAPrefix blocks the old mandate as a plain substring, so quoting it verbatim here would trip the gate on this very page.)

Imports and Concept Binding

Cross-file dependencies are declared with file-top use imports. The dotted path maps to a file on disk (library.concepts → dsl/library/concepts.memql); the brace list names the constructs pulled into local scope:

memql
use library.concepts.{ artifact, folder }
use library.shapes.{ artifactFull }
use common.traits.{ isActiveRecord }

The concept a construct binds to is named in its signature: query <Concept> <name>, mutation <Concept> <name>, shape <Concept> <name>, seed <Concept> <name>. The short concept name resolves through the file's use ...concepts.{ ... } import.

Retired: the @use* annotation family and @concepts(...). @useConcept, @useShape, @useQuery, @useMutation, @useLogic, @useBuiltin (and the rest of the @use* family), plus the @concepts("v1:...") shape binding, are retired and rejected at parse time. Use file-top use imports + signature binding instead.

Builtin Functions (Registry-Driven)

Builtins are declared in the DSL like every other construct -- in dsl/<namespace>/builtins.memql, struct form. The body's field list is the input schema; the implementation is the Go integration named by @executor:

memql
@executor("integration.auth.checkPermission")
@args(profile="object")
@description("Check if the current authenticated user has a specific role. Returns boolean result.")
builtin authCheckPermission {
role string @required
}

At runtime, parser resolution and executor dispatch are registry-driven from these declarations -- builtins resolve through the same function registry as user-defined functions, so they look like regular DSL calls with named arguments (authCheckPermission(role: "admin")). The object-literal call form (authCheckPermission({ role: "admin" })) is refused (memql#2335).


Syntax Principles

Consistent Accessor Pattern

Inputs are declared in an args { ... } block and read as args.X. Engine-provided values are bare top-level names, and a row is read through the lambda parameter that names it:

text
args.fieldName -- Caller-passed argument
actor.userId -- Resolved auth context (role, identityId, isClusterOwner, ...)
now -- RFC3339 timestamp captured at evaluation start
partition -- Active partition for this call
config.X -- Allow-listed config entry
row.fieldName -- Row payload field, through the parameter (filter row => ...)
row.id, row.createdAt, ... -- Row intrinsics, through the same parameter

A shape body is a path list rather than an expression, so there a bare fieldName is the payload field.

Retired: the ctx envelope. ctx.input.X and ctx.X are gone from the author surface; authors write args.X. The node("...") accessor wrapping used by legacy shape templates is also retired.

Operators

One expression language applies in every filter, spec, condition and value: memql.md is the reference, with the full operator table, the precedence and the table of absent values.

OperatorMeaningExample
== / !=Typed equality; != is true on an unset fieldrow.status != "archived"
> >= < <=Comparisonsrow.count >= 10
inMembershiprow.kind in ["a", "b"], args.tag in row.tags
startsWithString prefix (ANY of a list); an empty list and a blank prefix match nothing (authoring rules)row.codeReference startsWith args.prefixes
&& / || / !Boolean connectives and negationrow.folderId == args.folderId && isActiveRecord(row)
??Blank-coalescingargs.kind ?? "walkthrough"
c ? a : bConditional valueargs.flag ? "yes" : "no"
+Adds numbers, joins strings"si-" + hash(args.agentId)
( )Grouping(a || b) && c

A condition is boolean: there is no truthiness, and ! negates exactly. An optional argument is a plain predicate, args.x == nil || row.f == args.x, which the engine folds before the query runs. The retired spellings (when(...), ; and , as connectives, has, ?., cond(...), coalesce(...), concat(...), ...) are refused at parse wherever a .memql file writes them; the full list is memql.md, and memqlmigrate --rewrite=expressions rewrites them.


Query Functions

Queries are read-only struct constructs: signature-bound concept, optional args, a filter clause, optional sort / paginate directives, and a shape projection.

Syntax

memql
use library.concepts.{ artifact }
use library.shapes.{ artifactFull }
use common.traits.{ isActiveRecord, statusIsActive }
 
@description("Get live document artifacts filed under a folder")
query artifact activeDocumentArtifacts {
args {
folderId string @required
}
filter row => row.folderId == args.folderId
&& row.kind == "document"
&& statusIsActive(row)
&& isActiveRecord(row)
shape artifactFull
}

Filter rules:

  • The filter is a lambda, and every field is read through its parameter: a payload field is row.status, never a bare status, payload.status or <conceptName>.status.
  • A row intrinsic is read through the same parameter: row.id, row.concept, row.type, row.createdAt, row.createdBy, row.provenance.<leaf>. Intrinsics are reserved field names, so row.id (a table column) and row.status (a JSONB path) never collide.
  • A trait or spec is applied to the row: isActiveRecord(row). Where a trait covers the predicate it is mandatory -- the inline row.active == true is rejected when isActiveRecord exists (test/dslconformance/conformance_test.go).
  • A long filter continues on lines that open with &&, || or ??.

Optional filters

An optional argument is a plain predicate: args.x == nil || <predicate>. Nothing in it reads the row until the second half, so the engine computes args.x == nil once before the query runs: the clause becomes true when the argument is unset (left out, null or "") and the predicate alone when it is set. It replaces the retired when(args.x) { ... } guard, and under || the form is args.x != nil && <predicate>:

memql
use library.concepts.{ folder }
use library.shapes.{ libraryFolderFull }
use common.traits.{ isNotDeleted }
 
@description("Live folders, optionally narrowed to a creator")
query folder activeFolders {
args {
userId string
}
filter row => isNotDeleted(row)
&& row.archived != true
&& (args.userId == nil || row.createdBy == args.userId)
shape libraryFolderFull
}

Calling patterns (the internal query form a client sends):

text
activeFolders() -- No optional filter applied
activeFolders(userId: "u-1") -- Creator filter applied

Sorting and Pagination

memql
use work.concepts.{ run }
use work.shapes.{ workRunFull }
 
query run latestRunForGoal {
args {
goalId string @required
}
filter row => row.goalId == args.goalId
sort "row.createdAt", "desc"
paginate 1
shape workRunFull
}

A sort key names either a payload property ("version", bare) or a row intrinsic ("row.createdAt", namespaced). The two compile to different ORDER BY expressions -- a table column vs a JSONB path -- so in an authored .memql file the intrinsics take the row. namespace, exactly as they do in a filter predicate. See Reserved identifiers for the accepted leaves.

Counting

A count clause makes the query return the cardinality of the matching set as a self-describing {count: N} aggregate computed server-side, instead of the rows themselves:

memql
use identity.concepts.{ user }
use common.traits.{ isActiveRecord }
 
query user activeUserCount {
filter row => isActiveRecord(row)
count
}

count is mutually exclusive with shape, sort, and paginate (a count has no projection, ordering, or window). The count reflects the deduped, latest-version, post-filtered set -- the same row pipeline a normal query uses -- so it is correct under the time-series versioning model. Callers read count off the returned object rather than counting a row array.


Mutation Functions

Mutations write exactly one row of their signature-bound concept.

Execution Constraints

  • Exactly one bare insert { ... } OR update { ... } block per mutation body. update is the partial-update counterpart for read-merge-write flows.
  • Mutation functions can only be invoked as a top-level call, with named arguments: createFolder(folderId: "f-1", name: "Inbox") from a client, mutation createFolder(folderId: args.folderId, name: args.name) from a body.
  • Mutation functions cannot be wrapped with directives like shape(), paginate(), sort(), select(), asOf(), or withDepth().
  • Queries and specs cannot call mutations (compile-time CQS check).

Syntax

memql
use library.concepts.{ folder }
 
@description("Create a Library folder")
@actor
mutation folder createFolder {
args {
folderId string @required
name string @required
}
insert {
id: args.folderId
name: args.name
ownerUserId: actor.userId
createdAt: now
}
}

The write target comes from the signature -- the body never restates the concept id, and the named-write form (insert <concept> { ... }) is rejected (TestNoRetiredBindingForms). Reading the authenticated caller is a declared capability: a body that names actor.* carries a bare @actor annotation or the load fails (reserved identifiers).

Args Annotations and Defaults

args { ... } fields take @required (or the ! sigil after the type), @enum("a", "b"), @maxLength(N), @pattern("re"), @minimum(N) and @maximum(N). A field is described by a /// doc comment on the line above it; @description on an args field is refused at load.

Retired: @default on args fields. It was never applied and is rejected at load time. Apply a default in the body with the ?? blank-coalescing operator (it falls through on a blank or whitespace-only string as well as on absent/null -- see authoring-rules.md §28). A concept-field @default is NOT a substitute -- it is never applied on insert either (memql#2960):

memql
insert {
id: args.guideId
kind: args.kind ?? "walkthrough"
version: args.version ?? 1
active: args.active ?? true
}

a ?? b ?? c takes the first operand that is set, and the last one otherwise. ?? is the one spelling: the coalesce(a, b, c) call is retired, and memqlmigrate --rewrite=expressions rewrites it.

?? binds tighter than comparison and looser than arithmetic, so args.stage ?? "" == "active" means (args.stage ?? "") == "active", and args.n ?? 0 + 1 means args.n ?? (0 + 1) -- parenthesise when you want the sum coalesced.


Logic Functions

A logic is the imperative tier: a procedure an automation or another logic calls. It declares its inputs in args { ... }, and its statements follow, running in the order they are written, the last of them a return. The body language -- statements, names and scope, trailing clauses -- is specified in memql.md.

One statement (the common case)

memql
use workbench.queries.{ workspaceForRun }
 
/// Pure decide for the workspace-release sweep: every v1:workbench:workspace
/// of the updated run.
logic workspacesForTerminalRun {
args {
event object!
}
return query workspaceForRun(runId: args.event.payload.id ?? "")
}

A construct call names its kind and names every argument, and it is a statement of its own -- the whole value of return here, or the whole right-hand side of :=.

Several statements

name := <call> binds the call's value to name from the next statement on, and the return is the logic's value. A branch of an if runs at most once and shares the scope around it, so each branch may bind the same name and the statement after the if reads whichever one ran:

memql
use identity.queries.{ userById }
use agents.queries.{ agentById, assistantAgentForUser }
 
/// Resolve the agent to act as: the owner's preferred assistant, or their default one.
logic resolveActingAgent {
args {
ownerUserId string!
}
user := query userById(userId: args.ownerUserId)
preferred := user.first().preferences.activeAssistantId ?? ""
if preferred != "" {
agent := query agentById(agentId: preferred)
} else {
agent := query assistantAgentForUser(ownerUserId: args.ownerUserId)
}
return agent.first()
}

A later statement reads an earlier one's value by its name. A query's value is its rows, a row reads its payload fields by name (user.first().preferences), and the lowercase accessors and the collection library work over them: agent.first(), rows.empty(), rows.count(), rows.where(r => r.active).select(r => r.id) -- see memql.md. What every other kind of statement's value is, is under What runs.

A logic calls query, mutation, logic and builtin. Publishing and calling an automation or an action are an automation's, and a logic that does one is refused at load.


Automation Functions

Automations are event- or schedule-triggered workflows, written in the same body language plus the statements only an automation makes: publish, and a call of an automation or an action.

Event-Triggered

memql
use workbench.logic.{ runIsTerminal, releaseWorkspaceOnRunTerminal }
use workbench.mutations.{ releaseWorkspace }
use workbench.builtins.{ workbenchTeardownDirectory }
 
/// When a run reaches a terminal status, release its workbench workspace and
/// tear down its on-disk directory.
@trigger(event="graph.node.updated.v1:work:run")
@filter(row => row.status != "running" && row.status != "compiling" && row.status != "waiting")
automation releaseWorkspaceOnTerminalRun {
args {
id any
status any
}
 
terminal := logic runIsTerminal(status: args.status)
decide := logic releaseWorkspaceOnRunTerminal(event: event)
for item in decide.nodes() {
if terminal == true && item.status == "provisioned" {
mutation releaseWorkspace(
workspaceId: item.id,
reason: "run_terminal"
)
}
}
if terminal == true && args.id != nil {
teardown := builtin workbenchTeardownDirectory(runId: args.id)
}
}

The fields of the triggering row's payload are bound into the automation's args block and validated against it before the first statement runs; the body reads each as args.<field>. A dotted event.<field> read in an automation is refused at load. event is the event itself, and it is what an automation forwards when a logic wants the whole event: logic l(event: event). Inside that logic, event is an argument like any other -- it declares event object! in its args block and reads args.event.payload.<field>. The event binds identically across every invocation surface -- a real graph event, the live run_automation path, and the run_automation dry-run preview (memql#1727).

Scheduled

@trigger(schedule="...") takes a six-field cron expression (sec min hour dom mon dow):

memql
use cluster.logic.{ pruneStaleClusterNodes }
use cluster.mutations.{ updateNodeHealth }
 
/// Every 10 min: mark departed cluster nodes as health='stopped'.
@trigger(schedule="0 */10 * * * *")
automation pruneStaleClusterNodesOnSchedule {
decide := logic pruneStaleClusterNodes(event: event)
for node in decide {
mutation updateNodeHealth(
id: node.id,
health: "stopped",
lastSeen: now
)
}
}

Preconditions (self-healing)

An automation may declare one or more first-class precondition blocks, after its args block and before its first statement. A precondition is a deterministic boolean check (no LLM) evaluated at the start of the run -- after the trigger fires, but before any statement runs.

memql
use forge.mutations.{ advanceRequest }
 
/// Decide the status a submitted request moves to, from the submitter's role.
logic routeStatus {
args {
submitterRole any
}
role := args.submitterRole ?? ""
return role == "owner" ? "queued" : "needs_validation"
}
 
@trigger(event="node.created", concept="v1:forge:request")
@filter(row => args.firstVersion == true)
automation routeRequestWithPrecondition {
args {
id any
firstVersion bool
submitterRole any
}
precondition hasId {
check: args.id != nil
literal: id
description: "Route only a request that carries its id."
}
decide := logic routeStatus(submitterRole: args.submitterRole)
mutation advanceRequest(requestId: args.id, status: decide)
}

The @filter there is not part of the precondition mechanism -- it is loop protection, and it is here because the example needs it to load. advanceRequest writes the same concept the trigger fires on, so without a filter the write re-triggers the automation and the load is refused naming the cycle (loop_cycle). Narrowing the trigger to a request's first version is the filter the refusal itself suggests, and it is what the shipped recordRouted automation does. Do not copy it as precondition ceremony.

The check expression is an automation condition, written in the same expression language as every other. It reads only the roots -- args.<field> (the automation's declared args, bound from the trigger payload), actor, event, config.<key>, partition, now -- which the load checks, and may call catalog functions such as var("NAME"); comparisons, && / || / !. It must be boolean. args.X != nil is the presence check: an absent field, a null and an empty string are one unset value, so it is false for all three.

A precondition that evaluates false is a miss:

  1. The run aborts cleanly -- no statement runs, and the execution is recorded as skipped.
  2. The harness emits a structured healing.precondition.missed event (see events) carrying the automation + precondition identity, the failed check, the asserted literal, and the triggering event.

A miss is both the clean self-healing repair trigger and the cross-machine portability mechanism: a literal asserted by a precondition that does not hold on this machine is, by definition, a precondition that misses here. Fields:

FieldRequiredPurpose
checkyesThe deterministic boolean expression that must hold
literalnoNames the machine-specific literal asserted (path / id / endpoint) -- the portability hint the repair loop relativizes
descriptionnoHuman-readable context surfaced in the miss signal

Preconditions are evaluated in declaration order; the first miss wins and aborts the run. They are deterministic by design -- they guard the authored/deterministic spine but are never themselves LLM-healed.

Attribute Reference

Every annotation an automation accepts, and how each is written, is listed under automation in the attribute matrix, which is generated from the annotation registry. Each links to its entry, which gives its keys (the @trigger keys among them) and what it does.


Catalog

Every function and method an expression can call is one entry of the catalog in component/language/functions, and the table below is that catalog. Operators are not here (p ? a : b, a + b, a ?? b): see memql.md. Neither are the reserved roots, spec and trait applications, or construct calls (query activeUsers(...)).

  • Pushed down entries compile to SQL. A relationship traversal selects rows, so it lives in a query filter or spec body.
  • Pushed down on a row field entries compile to SQL when their input is a field of the row (row.title.includes(args.q), row.tags.any(t => ...)) and run in process on any other value.
  • In process entries run in the engine. In a filter or spec body they take only values that do not read the row, which are computed once before the query (plan constants): row.expiresAt < addDuration(now, "P1D") pushes down, lower(row.email) == args.email is refused.

A method is listed as list.<name> or string.<name> and called on its receiver: row.tags.any(t => t == "urgent"), args.title.count(). An absent receiver or argument does what each entry says.

SignatureRunsWhat it doesReplaces
addDuration(ts datetime, dur duration) datetimeIn processReturns ts moved by the ISO 8601 duration dur, as an RFC 3339 timestamp; a leading minus sign moves it back. An absent or unparseable argument is an error.
aliasOf(label? string, match lambda) rowsPushed downReturns the rows sharing an alias group with the rows selected by match; a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
canonicalId(value string, concept string) stringIn processReturns value as a canonical id of the named concept, whether it arrives as a bare short id or already canonical. An absent or blank value yields the empty string, and a value canonical for a different concept is an error.
childOf(label? string, match lambda) rowsPushed downReturns the children of the rows selected by match, the rows whose parent edge points at one of them; a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
contains(label? string, match lambda) rowsPushed downReturns the members of the collection rows selected by match, following their contains edges (the graph traversal: the substring test is string.includes); a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
createdBy(label? string, match lambda) rowsPushed downReturns the creators of the rows selected by match, following their createdBy edges; a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
daysBetween(a datetime, b datetime) numberIn processReturns the number of whole days from a to b, truncated toward zero and negative when b is earlier. An absent or unparseable argument is an error.
equals(label? string, match lambda) rowsPushed downReturns the rows joined to the rows selected by match by an equals edge; a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
error(message string) anyIn processRaises an error carrying message and ends the evaluation, so it never returns a value.
hash(value string) stringIn processReturns the SHA-256 digest of value as 64 lowercase hexadecimal characters. An absent value hashes as the empty string, so the result is always 64 characters wide.
ids(match lambda) rowsPushed downReturns the rows selected by match as id-only rows, without payload or schema. It follows no edge, so it takes no label.
lower(value string) stringIn processReturns value with its letters lowercased. An absent value yields the empty string.
owns(label? string, match lambda) rowsPushed downReturns the rows joined to the rows selected by match by an owns edge, in either direction; a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
parentOf(label? string, match lambda) rowsPushed downReturns the parents of the rows selected by match, following their parent edges; a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
references(label? string, match lambda) rowsPushed downReturns the rows joined to the rows selected by match by a references edge; a leading label follows only the edges whose as label it names. A traversal that finds nothing returns no rows, and a pointer that is absent or blank is skipped.
secret(name string) stringIn processReturns the decrypted value of the secret named name, looked up among the partition secrets and then the global ones. A name that matches no secret is an error, and the value must never be logged.
shortId(value string) stringIn processReturns the bare short id of value by stripping one canonical concept prefix; a value that is already bare comes back unchanged. An absent or blank value yields the empty string.
systemSecret(name string) stringIn processReturns the decrypted value of the global secret named name, with no partition lookup. A name that matches no secret is an error, and the value must never be logged.
systemVar(name string) stringIn processReturns the plaintext value of the global configuration variable named name, with no partition lookup. A name that matches no variable is an error.
toString(value any) stringIn processReturns value as text; a string comes back unchanged. An absent value yields the empty string.
trim(value string) stringIn processReturns value without its leading and trailing whitespace. An absent value yields the empty string.
upper(value string) stringIn processReturns value with its letters uppercased. An absent value yields the empty string.
var(name string) stringIn processReturns the plaintext value of the configuration variable named name, looked up among the partition variables and then the global ones. A name that matches no variable is an error.
list.all(pred lambda) boolPushed down on a row field, in process otherwiseReports whether pred holds for every element of the list. An empty or absent list answers true.
list.any(pred lambda) boolPushed down on a row field, in process otherwiseReports whether pred holds for at least one element of the list. An empty or absent list answers false.
list.avg(fn lambda) numberIn processReturns the mean of fn over the elements of the list. An empty or absent list yields an absent value, and fn returning a non-number is an error.
list.count() numberPushed down on a row field, in process otherwiseReturns the number of elements in the list. An absent list counts as zero. Over a row field it counts what is stored: an array's elements, a string's characters, and zero for anything else.len(x), count(x)
list.distinct(key? lambda) listIn processReturns the list with repeated elements dropped, keeping the first of each; with key, two elements repeat when key gives both the same value. An absent list yields an empty list.
list.empty() boolIn processReports whether the list has no elements. An absent list is empty.
list.first() anyIn processReturns the first element of the list. An empty or absent list yields an absent value; to take the first match, filter first: xs.where(x => p).first().first(x)
list.groupBy(key lambda) listIn processReturns one group per distinct value of key, in first-seen order, each a map holding key and items. An absent list yields an empty list.
list.last() anyIn processReturns the last element of the list. An empty or absent list yields an absent value.last(x)
list.max(fn lambda) numberIn processReturns the largest value of fn over the elements of the list. An empty or absent list yields an absent value, and fn returning a non-number is an error.
list.min(fn lambda) numberIn processReturns the smallest value of fn over the elements of the list. An empty or absent list yields an absent value, and fn returning a non-number is an error.
list.nodes() listIn processReturns the rows of a query result as a list; a list comes back unchanged. An absent value yields an empty list.
list.orderBy(key lambda) listIn processReturns the list sorted ascending by key, elements with equal keys keeping their order. An absent list yields an empty list.
list.orderByDesc(key lambda) listIn processReturns the list sorted descending by key, elements with equal keys keeping their order. An absent list yields an empty list.
list.reduce(seed any, fn lambda) anyIn processReturns seed folded through the list, calling fn with the accumulator and each element in turn. An empty or absent list returns seed unchanged.
list.select(fn lambda) listIn processReturns fn applied to each element of the list, in order. An absent list yields an empty list.
list.single() anyIn processReturns the one element of the list. Any other number of elements, none included, is an error; to take the one match, filter first: xs.where(x => p).single().
list.skip(n number) listIn processReturns the list without its first n elements; a negative n skips none. An absent list yields an empty list.
list.sum(fn lambda) numberIn processReturns the sum of fn over the elements of the list. An empty or absent list sums to zero, and fn returning a non-number is an error.
list.take(n number) listIn processReturns the first n elements of the list, or all of them when there are fewer; a negative n takes none. An absent list yields an empty list.
list.where(pred lambda) listIn processReturns the elements of the list that pred holds for, in order. An absent list yields an empty list.
string.count() numberIn processReturns the number of characters in the string, counting Unicode code points rather than bytes. An absent string counts as zero.
string.includes(sub string) boolPushed down on a row field, in process otherwiseReports whether sub occurs in the string. A blank sub matches nothing, as a blank prefix does for startsWith, and an absent string or sub answers false.contains(s, sub)

The spellings the catalog replaces with an operator (cond, concat, coalesce, exists, ...) are listed with every other retired form in memql.md.

Roots

These are names, not calls:

NameDescriptionExample
args.nameCaller-passed argument, as declared in args { }args.folderId
actor.XAuth context (userId, role, identityId, isClusterOwner)actor.userId
nowEval-start timestamp (bare name)createdAt: now
config.XAllow-listed config entryconfig.someKey
eventThe triggering event, in an automationevent.payload.status

var("NAME") reads a named configuration variable (v1:platform:variable / v1:platform:partitionVariable); it is in the catalog above.

AI

There is no AI function in the catalog above, and there is no bare AI call. A bare ai(promptName, data) is refused at load with body_call_unknown, because the only bare calls a body admits are catalog functions and the specs and traits it can see — and ai is deliberately not one of them. A catalog function is admitted at every in-process position, a trigger filter included, which for a model call would be one call per matching event with nothing journalling it.

AI work is reached from a body the way every other Go-backed capability is: through a builtin call, with named arguments and a file-top import of the domain that declares it (use agents.builtins.{ ai }). See Calling a prompt.

BuiltinDescriptionCalled as
aiCalls one named prompt with a data object and returns {prompt, reply}. Synchronous; the prompt's @level and the routing rules choose the model, so the call never names onebuiltin ai(templateId: "docSummary", data: { content: args.content })
agentOpens a v1:work:goal naming the invokeAgent template and returns {goalId, runId}; a run dispatcher claims it on an agent nodebuiltin agent(name: "assistant", prompt: args.question, partitionId: "system")
runAgentTurnRuns one agent turn in line and returns its reply. Answers only on an agent nodebuiltin runAgentTurn(agentId: args.agentId, prompt: args.question)

All three are declared in dsl/agents/builtins.memql, which is where their full field lists live. All three are construct calls, so they belong in a statement: a query filter, a refine, a sort and a spec or trait body all refuse them, which is what keeps a model call out of a per-row position.


Prompt Functions

Prompts define AI templates with typed input schemas and a default provider. Struct form: the body is a bare field list -- it IS the input schema. Declared in dsl/<namespace>/prompts.memql; the rendered template is a Go text/template file named by @templateFile.

Syntax

memql
@level("fast")
@templateFile("prompts/condenseConversation.tmpl")
@description("Summarize older conversation messages into a rolling summary.")
prompt condenseConversation {
entries []object @required @description("Conversation messages, oldest first.")
previousSummary string @description("Prior rolling summary; empty on first compaction.")
}

@defaultProvider is an explicit pin, and this example does not use one. @level is how a prompt says how much intelligence it needs; the rules and policies then decide which provider answers. A pin overrides all of that, and pinning one to a paid provider is refused in this repository by TestNoPaidDefault -- every concrete provider record here is federated, so a pin naming one routes around the local-first rule the platform ships.

Retired prompt forms (both rejected at parse time):

  • func (Prompt) name(args any) { ... } -- receiver-function wrapping.
  • @input { ... } -- body-level wrapper around the field list. The field list is the body now.

Attributes

Every annotation a prompt accepts (@level is required on every prompt) and every annotation its input fields accept are listed under prompt and prompt field in the attribute matrix, which is generated from the annotation registry.

Input Field Types

TypeDescription
string / int / float / booleanScalars
objectJSON object
[]objectArray of JSON objects
@requiredField modifier marking the field as required
@description("...")Per-field documentation surfaced to the AI layer

Provider Functions

Providers define AI vendor + model configurations (OpenAI and Anthropic are the supported vendors). Struct form, consolidated in dsl/providers/providers.memql.

Syntax

memql
@extends("openai")
@model("gpt-5.4-mini")
@description("OpenAI GPT-5.4 Mini -- balanced cost/latency chat")
provider chat54Mini {
params {
contextWindow 128000
maxCompletionTokens 16384
}
}

Base providers carry vendor-level auth and type; children inherit via @extends:

memql
@base
@vendor("Anthropic")
provider anthropic {
auth {
federationRuleId env("MEMQL_AI_ANTHROPIC_FEDERATION_RULE_ID")
organizationId env("MEMQL_AI_ANTHROPIC_ORGANIZATION_ID")
serviceAccountId env("MEMQL_AI_ANTHROPIC_SERVICE_ACCOUNT_ID")
identityTokenFile env("MEMQL_AI_ANTHROPIC_IDENTITY_TOKEN_FILE")
}
}

Retired: func (Provider) name { ... } is rejected at parse time with a migration hint.

Attributes

Every annotation a provider accepts, and how each is written, is listed under provider in the attribute matrix, which is generated from the annotation registry. @disabled on a provider skips it at load with no auth resolution attempted, and on a @base it skips every child that @extends it.

Blocks

BlockDescription
authCredentials via env() environment-variable references. Inherited from the base when using @extends.
paramsProvider-specific parameters (contextWindow, maxCompletionTokens, cost, etc.)

Tool Functions

Tools are the AI-callable surface over queries, mutations, and builtins, declared in dsl/<namespace>/tools.memql. The body is the tool's input schema; @handler binds it to the operation it runs:

memql
use calendar.queries.{ findEvents }
 
@handler(type="query", query="query findEvents(title: args.title)")
@executionTime("fast")
@description("Find the caller's calendar events by exact title.")
tool calendarFindByTitle {
title string @required @description("Exact event title to look up.")
}

Tool body fields take @required, @default("..."), @enum, and @description. (Tool fields are the one place @default is valid -- it is rejected on query / mutation args fields.) The legacy func (Tool) form is retired; the parser rejects it with a migration hint. What a query handler's call and a webhook's url may be written as, and the retired $args. placeholder, are in the language reference.


Shape Functions

Shapes are reusable field-projection templates, declared in struct form in dsl/<namespace>/shapes.memql. Each shape declares its kind via @row (concept payload + row intrinsics) and/or @actor (auth-context envelope); at least one is required. The body is a path list -- each path becomes a template entry keyed by the path's terminal segment.

Row Shapes

The bound concept is named by the signature shape <Concept> <name> (resolved through the file-top concept import):

memql
use library.concepts.{ folder }
 
@description("Folder summary card")
@row
shape folder folderCard {
row.id
name
parentFolderId
row.createdAt
}

Actor Shapes

Project the engine envelope; no signature concept. Closed field set: actor.userId / actor.role / actor.identityId / actor.isClusterOwner / actor.primaryEmail / actor.now (the isOwner spelling is a legacy alias of isClusterOwner; actor.config.<key> is retired -- read config through the bare reserved config.<key>, #2623):

memql
@description("Actor identity envelope")
@actor
shape actorEnvelope {
actor.userId
actor.role
actor.identityId
actor.isClusterOwner
}

No composition

include is not a shape verb. It was documented here for a long time and never implemented (memql#3621): a shape body is a path list, so include folderCard parsed as two payload properties and projected two always-null keys. It is rejected at load now. To share a projection, repeat the paths, or drop the body entirely and take the default projection over the bound concept (memql#2035).

What the loader checks

Every shape is validated against the concept it binds (memql#3621):

  • a bare payload property must be a declared field of the bound concept -- createdAt written bare is payload.createdAt, which is almost certainly the row intrinsic row.createdAt misspelled;
  • the bound concept must resolve; an ambiguous bare name (plan, request, call, invocation all collide across namespaces) disambiguates through the shape's own domain;
  • two paths may not collapse onto the same terminal key -- every path is keyed by its last segment, so row.id + id used to yield one entry and lose the row id;
  • the declared kind must match the body: actor.* needs @actor, row.* / bare payload needs @row, at least one is required, and actor.* members are the closed envelope set (#2623).

Usage in Queries

Struct queries reference a shape by name in their shape clause:

memql
use library.concepts.{ artifact }
use library.shapes.{ artifactFull }
use common.traits.{ isActiveRecord }
 
query artifact folderArtifacts {
args {
folderId string @required
}
filter row => row.folderId == args.folderId && isActiveRecord(row)
shape artifactFull
}

Retired shape forms (rejected at parse time): func (Shape) name { ... } receiver wrapping, the @concepts("v1:...") binding annotation, the @template({...}) body annotation, and node("path") accessors. Shapes have no inputs and no return; the body is a path list.