
Omnigraph
- 34 installs
- 1k repo stars
- Updated August 5, 2026
- modernrelay/omnigraph
Helps with ai & agent building tasks.
About
omnigraph is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- omnigraph
- AI & Agent Building
- AI-coding skill
Omnigraph by the numbers
- 34 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #8,822 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/modernrelay/omnigraph --skill omnigraphAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 34 |
|---|---|
| repo stars | ★ 1k |
| Last updated | August 5, 2026 |
| Repository | modernrelay/omnigraph ↗ |
What it does
Helps with ai & agent building tasks.
Files
Operating Omnigraph Locally
This skill captures the operational rules for working with a locally or remotely deployed Omnigraph. Follow them when authoring schema, writing queries, loading data, evolving schema, or automating graph operations.
The Seven Rules
1. Lint before commit — omnigraph lint --schema schema.pg --query queries/foo.gq validates both sides against each other. No running repo required. 2. Plan before apply — never run schema apply without a successful schema plan first. Apply is destructive; plan is free. (Cluster mode has the same rule with different verbs: cluster plan before cluster apply — the plan embeds the engine's real migration steps.) 3. Branches are for data; apply is for schema — review bulk data loads on a feature branch then merge. Schema changes go straight to main: in cluster mode edit the .pg and run cluster apply (a direct schema apply refuses a cluster-managed graph); schema plan/apply is for a non-cluster store. 4. Pick the right write command — mutate for edits (typechecked, parameterized); load for bulk JSONL, local or remote, with a required --mode (merge upsert · append strict-insert · overwrite clean-slate). load --from <base> forks a review branch in one shot; bare load needs an existing target branch. 5. Parameterize everything — never string-interpolate values into .gq bodies or --params. Declare $var: Type and pass via --params. 6. Expose agent operations as aliases — not raw CLI invocations. Aliases decouple the operation name from the query implementation. 7. Verify after every remote write — compare commit list --branch main head before and after. The CLI's exit code is not authoritative on remote graphs; proxies can drop the response while the write commits server-side. See references/remote-ops.md for the verification ritual and how to recover from 504s.
Essentials: Queries, Mutations, Loads
The patterns below cover the daily 80% — enough to write correct .gq and JSONL without leaving this file. The long tail (multi-hop, negation, aggregations, hybrid search, every decorator) is in `references/queries.md` and `references/schema.md`.
Comments in `.pg` and `.gq` are `//`, never `#` (the #1 parse error).
Read query (.gq)
query get_signal($slug: String) {
match {
$s: Signal { slug: $slug } // inline property filter goes in the match block
$s formsPattern $p // edge FormsPattern declared PascalCase, traversed lowerCamelCase
}
return { $s.slug, $s.name, $p.slug }
}- Parameterize, never interpolate. Declare
$var: Typein the signature; pass via--params '{"slug":"sig-foo"}'. An empty signature still needs parens:query foo() { ... }. - Edge traversal is lowerCamelCase even though the schema declares edges PascalCase (
FormsPattern→formsPattern). - List/sort by appending
order { $s.stagingTimestamp desc } limit 50afterreturn. - Ranking ops (`nearest`/`bm25`/`rrf`) require a trailing `limit N` — omitting it is a compile error. They live in
order { }, not as filters. Scope withmatch/filters first, then rank (order { nearest($d.embedding, $q) } limit 10).
Mutation (.gq)
There is no top-level `mutation { }` — every block is a named query; the verb (insert/update/delete) makes it a write. Dispatch with omnigraph mutate (not query).
query add_signal($slug: String, $name: String, $brief: String, $createdAt: DateTime) {
insert Signal { slug: $slug, name: $name, brief: $brief,
stagingTimestamp: $createdAt, createdAt: $createdAt, updatedAt: $createdAt }
}
query link($from: String, $to: String) { insert FormsPattern { from: $from, to: $to } }
query retitle($slug: String, $t: String) { update Signal set { name: $t } where slug = $slug }
query remove($slug: String) { delete Signal where slug = $slug }- Every non-nullable property must be supplied or lint fails (
T12: insert for 'Signal' must provide non-nullable property 'X'). - A single mutation is insert/update-only or delete-only — never both (parse-time D₂ rule); split them.
- Edges have no
@key: givefrom/toslugs; the property block is{}when the edge has none.
Bulk load (JSONL)
{"type":"Signal","data":{"slug":"sig-foo","name":"Foo","brief":"…","stagingTimestamp":"2026-04-14T00:00:00Z","createdAt":"2026-04-14T00:00:00Z","updatedAt":"2026-04-14T00:00:00Z"}}
{"edge":"FormsPattern","from":"sig-foo","to":"pat-bar","data":{}}omnigraph load --data seed.jsonl --mode merge $GRAPH # --mode is REQUIRED (no default)
omnigraph load --data delta.jsonl --from main --branch review --mode merge $GRAPH # fork a review branch in one shot--mode:merge(upsert by@key) ·append(fails on collision) ·overwrite(destructive, staged).--from <base>forks a missing--branch; bareloadneeds an existing branch. Works local and remote.- Date footgun:
mutate --paramstakes ISO strings (Date"2026-04-29",DateTime"…T00:00:00Z");loadJSONL takes integer days since epoch forDate(20572) but ISO forDateTime.
Dispatching
omnigraph alias signal sig-foo # operator alias → its bound stored query (read or write)
omnigraph query get_signal --params '{"slug":"sig-foo"}' # served stored query by name (verb asserts read vs write)
omnigraph query -e 'query q() { match { $s: Signal } return { $s.slug } limit 5 }' # ad-hoc/inline (or: --query f.gq <name>)
omnigraph mutate add_signal --query mutations.gq --params '{"slug":"sig-foo", ...}' # name positional; ad-hoc file source
omnigraph lint --schema schema.pg --query queries/foo.gq # after EVERY .gq/.pg edit (no server needed).gq grammar
The non-obvious facts that bite, then the full grammar:
- Scalar param types:
String Bool I32 I64 U32 U64 F32 F64 DateTime Date Blob. Modifiers:T?(optional),[T](list),Vector(N). There is no `Int` — useI64. - *A read query needs `match` and `return`* (
order/limitoptional); a mutation has neither — onlyinsert/update/delete. - `limit` takes an integer literal, not a param —
limit 50, neverlimit $n. - Variable-hop traversal:
$p knows{1,3} $f({1,}= unbounded). - Literals & calls:
now(),date("2026-04-29"),datetime("…T00:00:00Z"), list[…]. - Filters
= != > < >= <= contains; aggregatescount/sum/avg/min/max(count($f) as n). - Stored-query metadata:
@description("…")/@instruction("…")may follow the param list. - Casing: type names uppercase-initial (
Signal); idents/edges lowercase-initial (formsPattern); variables$-prefixed.//and/* */comments only.
Authoritative PEG grammar (pest) for .gq files ("NanoGraph" is the legacy engine name):
// NanoGraph Query Grammar (.gq files)
WHITESPACE = _{ " " | "\t" | "\r" | "\n" }
COMMENT = _{ LINE_COMMENT | BLOCK_COMMENT }
LINE_COMMENT = _{ "//" ~ (!"\n" ~ ANY)* }
BLOCK_COMMENT = _{ "/*" ~ (!"*/" ~ ANY)* ~ "*/" }
query_file = { SOI ~ query_decl* ~ EOI }
query_decl = {
"query" ~ ident ~ "(" ~ param_list? ~ ")" ~ query_annotation* ~ "{"
~ query_body
~ "}"
}
query_annotation = { description_annotation | instruction_annotation }
description_annotation = { "@description" ~ "(" ~ string_lit ~ ")" }
instruction_annotation = { "@instruction" ~ "(" ~ string_lit ~ ")" }
query_body = { read_query_body | mutation_body }
mutation_body = { mutation_stmt+ }
read_query_body = {
match_clause
~ return_clause
~ order_clause?
~ limit_clause?
}
mutation_stmt = { insert_stmt | update_stmt | delete_stmt }
insert_stmt = { "insert" ~ type_name ~ "{" ~ mutation_assignment+ ~ "}" }
update_stmt = { "update" ~ type_name ~ "set" ~ "{" ~ mutation_assignment+ ~ "}" ~ "where" ~ mutation_predicate }
delete_stmt = { "delete" ~ type_name ~ "where" ~ mutation_predicate }
mutation_assignment = { ident ~ ":" ~ match_value ~ ","? }
mutation_predicate = { ident ~ comp_op ~ match_value }
param_list = { param ~ ("," ~ param)* }
param = { variable ~ ":" ~ type_ref }
type_ref = { (list_type | base_type | vector_type) ~ "?"? }
list_type = { "[" ~ base_type ~ "]" }
vector_type = { "Vector" ~ "(" ~ integer ~ ")" }
base_type = { "String" | "Blob" | "Bool" | "I32" | "I64" | "U32" | "U64" | "F32" | "F64" | "DateTime" | "Date" }
match_clause = { "match" ~ "{" ~ clause+ ~ "}" }
clause = { negation | binding | traversal | filter | text_search_clause }
text_search_clause = { search_call | fuzzy_call | match_text_call }
// Binding: $p: Person { name: "Alice" }
binding = { variable ~ ":" ~ type_name ~ ("{" ~ prop_match_list ~ "}")? }
prop_match_list = { prop_match ~ ("," ~ prop_match)* ~ ","? }
prop_match = { ident ~ ":" ~ match_value }
match_value = { literal | variable | now_call }
// Traversal: $p knows $f
traversal = { variable ~ edge_ident ~ traversal_bounds? ~ variable }
traversal_bounds = { "{" ~ integer ~ "," ~ integer? ~ "}" }
// Filter: $f.age > 25
filter = { expr ~ filter_op ~ expr }
// Negation: not { ... }
negation = { "not" ~ "{" ~ clause+ ~ "}" }
// Return clause — projections separated by commas or newlines
return_clause = { "return" ~ "{" ~ projection+ ~ "}" }
projection = { expr ~ ("as" ~ ident)? ~ ","? }
// Order clause
order_clause = { "order" ~ "{" ~ ordering ~ ("," ~ ordering)* ~ "}" }
ordering = { nearest_ordering | (expr ~ order_dir?) }
nearest_ordering = { "nearest" ~ "(" ~ prop_access ~ "," ~ expr ~ ")" }
order_dir = { "asc" | "desc" }
// Limit clause
limit_clause = { "limit" ~ integer }
// Expressions
expr = { now_call | nearest_ordering | search_call | fuzzy_call | match_text_call | bm25_call | rrf_call | agg_call | prop_access | variable | literal | ident }
now_call = { "now" ~ "(" ~ ")" }
search_call = { "search" ~ "(" ~ expr ~ "," ~ expr ~ ")" }
fuzzy_call = { "fuzzy" ~ "(" ~ expr ~ "," ~ expr ~ ("," ~ expr)? ~ ")" }
match_text_call = { "match_text" ~ "(" ~ expr ~ "," ~ expr ~ ")" }
bm25_call = { "bm25" ~ "(" ~ expr ~ "," ~ expr ~ ")" }
rank_expr = { nearest_ordering | bm25_call }
rrf_call = { "rrf" ~ "(" ~ rank_expr ~ "," ~ rank_expr ~ ("," ~ expr)? ~ ")" }
prop_access = { variable ~ "." ~ ident }
agg_call = { agg_func ~ "(" ~ expr ~ ")" }
agg_func = { "count" | "sum" | "avg" | "min" | "max" }
comp_op = { ">=" | "<=" | "!=" | ">" | "<" | "=" }
filter_op = { "contains" | comp_op }
// Terminals
variable = @{ "$" ~ (ident_chars | "_") }
ident_chars = @{ (ASCII_ALPHA_LOWER | "_") ~ (ASCII_ALPHANUMERIC | "_")* }
// Edge identifier — lowercase start, same as ident but used in traversal context
// Must not match keywords
edge_ident = @{ !("not" ~ !ASCII_ALPHANUMERIC) ~ (ASCII_ALPHA_LOWER | "_") ~ (ASCII_ALPHANUMERIC | "_")* }
type_name = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHANUMERIC | "_")* }
ident = @{ (ASCII_ALPHA_LOWER | "_") ~ (ASCII_ALPHANUMERIC | "_")* }
literal = { list_lit | datetime_lit | date_lit | string_lit | float_lit | integer | bool_lit }
date_lit = { "date" ~ "(" ~ string_lit ~ ")" }
datetime_lit = { "datetime" ~ "(" ~ string_lit ~ ")" }
list_lit = { "[" ~ (literal ~ ("," ~ literal)*)? ~ "]" }
string_lit = @{ "\"" ~ string_char* ~ "\"" }
string_char = @{ !("\"" | "\\") ~ ANY | "\\" ~ ANY }
float_lit = @{ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ }
integer = @{ ASCII_DIGIT+ }
bool_lit = { "true" | "false" }CLI Reference (condensed)
Notation: <x> required · [x] optional · <a|b> choice · … repeatable.
Global addressing flags: --as <actor> (direct/--store writes only — a server resolves the actor from its token), --server <name|url>, --cluster <dir|uri> (cluster-managed storage, for maintenance), --graph <id> (selects the graph within a --server or --cluster scope), --profile <name> ($OMNIGRAPH_PROFILE), --store <uri>. Data commands also take a positional file:///s3:// URI (--config <dir> is for cluster commands only). Output: --json, or reads take --format <json|jsonl|csv|kv|table>. Write guards: --yes skips the confirm prompt for a destructive write (cleanup, overwrite load, branch delete) against a non-local scope (it refuses without it when non-TTY or --json); --quiet suppresses the resolved-target echo.
Data plane — any (served via --server/--profile, or direct via --store/URI):
query(aliasread)<name>— a served stored query by name (via--server/--profile); or ad-hoc[<name>] (--query <f.gq> | -e '<GQ>')where<name>picks which query in the source.[--params <json> | --params-file <p>] [--branch <b> | --snapshot <id>] [--format <fmt> | --json]. No positional URI — address via--server/--store/--profile.mutate(aliaschange) — same shape (served stored mutation by<name>, or ad-hoc--query/-e);[--params …] [--branch <b>] [--json]. The verb asserts kind:query→read,mutate→write (400 on mismatch).alias <name> [args…]— invoke an operator alias's bound stored query (read or write);[--params … | --params-file <p>] [--format <fmt> | --json](server/graph/query come from the binding)load --data <f.jsonl> --mode <overwrite|append|merge> [--branch <b>] [--from <base>] [--json]—--moderequired;--fromforks a missing--branchsnapshot [--branch <b>] [--json]export [--branch <b>] [--type <T>…] [--table <K>…](streams JSONL)branch <create <name> [--from <base>] | list | delete <name> | merge <source> --into <target>> [--json]commit <list [--branch <b>] | show <commit_id>> [--json]schema <plan | apply> --schema <f.pg> [--allow-data-loss] [--json]·schema show(aliasget) —applyrefuses a cluster-managed graph (evolve those viacluster apply)
Served only (needs --server/--profile): graphs list [--json]
Direct / storage — reject --server; address by positional URI or --cluster <dir|s3> --graph <id>:
init --schema <f.pg> <uri> [--force]lint --query <f.gq> [--schema <f.pg>] [<uri>] [--json]— offline with--schema, graph-backed with a URIoptimize [--json]·repair [--confirm] [--force] [--json]·cleanup (--keep <N> | --older-than <7d>) --confirm [--json]queries <validate [<uri>] | list> [--json]
Control plane — cluster (--config <dir>, default .):
cluster <validate | plan | apply | status | refresh | import> [--config <dir>] [--json]cluster approve <resource> --as <actor> [--config <dir>] [--json]·cluster force-unlock <lock_id> [--config <dir>] [--json]
Local (no graph):
policy <validate | test --tests <f> | explain --actor <a> --action <act> [--branch <b> | --target-branch <b>]> --cluster <dir> [--graph <id>]embed --seed <embed.yaml> [--reembed_all | --clean | --select "<Type>:<field>=<value>"]login <server> [--token <t>](prefer piping the token on stdin) ·logout <server>·profile <list | show [<name>]>·version
Pre-0.7.0 spellings (read/change/ingest, --target, positional http://) → `references/migrations.md`.
Five Ontology Design Criteria (Gruber 1993)
Omnigraph schemas are ontologies. The canonical design criteria from Gruber's Toward Principles for the Design of Ontologies Used for Knowledge Sharing (Int. J. Human-Computer Studies 43:907–928) apply directly when authoring .pg files.
1. Clarity — definitions should communicate intended meaning unambiguously and be independent of social or computational context. In Omnigraph: precise type names, narrow enums over String, @check/@range for stated invariants. A reviewer should understand the domain from the schema alone. 2. Coherence — inferences sanctioned by the schema must be consistent with the domain modeled. Gruber's trap: defining quantity as a (magnitude, unit) pair makes 6 feet ≠ 2 yards even though they describe the same length. In Omnigraph: watch for @card, @unique, and edge directionality that let the schema distinguish things the domain treats as equal. 3. Extendibility — the schema should support specialization without revising existing definitions. In Omnigraph: prefer interfaces for shared shape, leave enums open where the domain genuinely admits more, model identifiers via mapping functions rather than baking units/formats into the entity. 4. Minimal encoding bias — representation choices made for notation or implementation convenience leak into the model. In Omnigraph: don't type dates as String because the source API returns strings; separate conceptual entities (a publication date, a person) from their surface encoding (a year integer, a name string) when both matter. 5. Minimal ontological commitment — make as few claims about the world as the use case requires. In Omnigraph: don't add required properties, closed enums, or @card(1..1) "in case"; tighten later via schema plan/apply when a real constraint emerges. Weaker schemas leave consumers room to specialize.
The criteria trade off against each other — Clarity wants tight definitions while Minimal Commitment wants weak ones. Gruber's resolution: having decided a distinction is worth making, give it the tightest possible definition. Decide what to model conservatively; once modeled, constrain precisely.
Schema Authoring Principles
Twelve practical rules for .pg authoring — full text and examples in `docs/omni-schema.md`. In short: schema-is-the-contract · explicit identity via @key · model meaning not tables · strong intentional types · deliberate optionality · shared shape in interfaces · schema-level constraints (@unique/@index/@range/@check/@card) · search as a schema decision · edge semantics matter · reviewable schemas · intentional migrations (@rename_from) · domain clarity over ORM habits.
Design flow: entities → stable keys → relationships worth their own edge → enum candidates → uniqueness/bounds/cardinality → search needs → shared shape into interfaces → evolution plan.
Provenance Is Structural (Multi-Agent Source of Truth)
When Omnigraph serves as canonical truth across multiple agents, every assertion must answer who said it, when, based on what evidence. This is the runtime guarantee Gruber's criteria don't cover — his agents shared vocabulary; ours additionally must share attribution. Provenance belongs in the schema, not in logs.
Without structural provenance, agents cannot reconcile contradictory assertions, retract facts when a source is discredited, replay graph state at a past timestamp, or distinguish high-evidence facts from speculation.
In Omnigraph: model provenance as a Claim-style interface (or a separate Claim node linked to each sourced fact) with required fields — asserted_by: Actor, asserted_at: DateTime, evidence_source: Source, optionally confidence: F64. Don't stash provenance into a free-text source: String or a metadata: JSON dump — structured provenance is queryable, indexable, and migratable; free-form is none of these.
Storage & Credentials
A graph's bytes live in one of two backends:
- Local filesystem — a path or
file://URI. In cluster modestorage:defaults to the config directory, so local dev needs no object store. - S3-compatible object storage — AWS, Railway, Tigris, etc. (
s3://bucket/prefix). Authenticate with the standardAWS_*environment contract; keep dev creds in a git-ignored.env.omniand source it before CLI calls:
set -a && source .env.omni && set +ainit and load write storage directly (bypassing the server); the server reads from it. Validate with curl http://127.0.0.1:8080/healthz, then omnigraph snapshot <graph-uri> --json.
Project Layout
Deployment & access (omnigraph >= 0.7.0)
- Cluster deployment — the only way to serve. A
cluster.yamldeclares the
whole deployment (graphs, schemas, stored queries, policies, optional S3 storage: root); omnigraph cluster apply converges it and omnigraph-server --cluster . (or --cluster s3://bucket/prefix, config-free) serves it. See references/cluster.md.
- Direct / embedded access — no server. Address a graph's storage directly
with --store <file://|s3:// uri> or a positional URI for one-off CLI ops. There is no single-graph server mode — the server is cluster-only.
The two config surfaces (omnigraph >= 0.7.0)
Configuration has two single-owner homes (RFC-007/008), plus an everything-explicit flag/env tier:
| Surface | Owner | Location | Declares |
|---|---|---|---|
| Cluster config | the team, in the repo | cluster.yaml + the .pg/.gq/policy files it references | what the system is: graphs, schemas, queries, policies, storage |
| Operator config | one person | ~/.omnigraph/config.yaml ($OMNIGRAPH_HOME relocates it) | who I am: identity, named servers, output defaults, personal aliases |
| Flags / env | per invocation | — | everything, explicitly |
# ~/.omnigraph/config.yaml — per operator, never committed
operator:
actor: act-andrew # default --as identity
servers:
intel-dev:
url: https://graph.example.com # no tokens here, ever
defaults:
output: table # read-format default
server: intel-dev # default served scope (or `store: file://…/g.omni` for a local default — mutually exclusive)
default_graph: spike # graph within a server/cluster scope
profiles: # optional named scope bundles — pick with --profile <name>
staging: { server: intel-staging, default_graph: spike }
aliases: # personal bindings to TEAM stored queries (see references/aliases.md)
triage: { server: intel-dev, graph: spike, query: weekly_triage, args: [since] }The operator config and credentials are auto-discovered — no flag points at them: the CLI reads $OMNIGRAPH_HOME/config.yaml (default ~/.omnigraph/config.yaml), and an absent file is just an empty layer (zero-config). $OMNIGRAPH_HOME relocates the directory only, not a specific file. (--config/$OMNIGRAPH_CONFIG is a separate flag for the cluster / server config — not this.)
Credentials live outside config: echo $TOKEN | omnigraph login intel-dev writes ~/.omnigraph/credentials (0600); the matching token resolves via OMNIGRAPH_TOKEN_INTEL_DEV or that file.
Addressing a graph: --store <file://|s3:// uri> or a positional URI for direct storage; --server <name|url> (+ --graph <id>) for a served remote; --profile <name> for a named bundle; else the operator defaults. A remote is addressed with --server (a bare http(s):// URL is not a graph address). Run data-plane commands from a graph's project folder so relative queries/, schema.pg, and .env.omni paths resolve.
What to commit
Commit: schema.pg, queries/*.gq, cluster.yaml, seed.md, seed.jsonl, and the project's README.md and CLAUDE.md.
Ignore: .env.omni (credentials), .claude/ (local agent state), *.omni/ (local graph artifacts), __cluster/ and graphs/ (cluster state + derived graph roots).
Give agents a CLAUDE.md
A per-project CLAUDE.md tells coding agents where files live and what conventions matter. Without it, agents re-discover the same things every session.
Common Gotchas
These are the traps most likely to bite. Scan this table before debugging any parse or runtime error.
| Trap | Symptom | Fix |
|---|---|---|
# comments in .pg | parse error: expected schema_file | Use // |
Standalone enum Foo { ... } block | parse error: expected EOI or schema_decl | Inline: kind: enum(a, b) |
[Category] (list of enum) | compile error | Use [String]; lists must contain scalars |
@embed(text) without quotes | unexpected constraint_name | @embed("text") |
@unique(src) on edge without body block | parse error | @card(1..1) { @unique(src) } |
load --mode merge after @embed source change | stale embeddings | omnigraph embed --reembed_all or load --mode overwrite |
schema apply with feature branches open | rejected | Merge or delete branches first |
nearest(...) / bm25(...) / rrf(...) without limit | compile error | Add limit N |
| Adding non-nullable property without backfill | unsupported migration | Make optional → backfill → tighten in follow-up apply |
omnigraph init --json | unexpected argument --json | init doesn't support --json; drop the flag |
omnigraph init on an already-initialized URI | AlreadyInitialized error (v0.6.0+) | --force to re-init (skips the schema preflight; does not purge data) |
schema apply dropping a property/type | soft-dropped or rejected (no data loss) | add --allow-data-loss to actually drop the column |
Committing .env.omni | credential leak | Add .env* to .gitignore |
| Non-parameterized query values | typecheck surprise, injection risk | Declare $param: Type and pass via --params |
Missing required field in insert | T12: insert for 'X' must provide non-nullable property 'Y' | Accept the param in the mutation signature |
| Long-lived feature branches | merge conflicts, schema apply blocked | Merge promptly; delete when done |
mutation { ... } wrapper in .gq | parse error: expected query_file at line 1 | Use query <name>(...) { insert T { ... } }; there is no top-level mutation keyword |
--config placed before subcommand | unexpected argument --config | Put --config after the subcommand (e.g. omnigraph schema show --config X) |
| Reading a large schema via stdout-capped tool | Truncated, garbled, or duplicated output | omnigraph schema show > /tmp/schema.pg first; then read the file with offset/limit |
omnigraph load without --mode | error: --mode is required | Pass `--mode merge\ |
| Blind retry after 504 | Duplicate Signal/Decision/Claim (append-only types lack @key dedup) | commit list --branch main --json first; head advanced means it landed; only retry if unchanged |
sync_branch() mentioned in version-drift error | Searching for nonexistent CLI command | Server-internal directive in error text; just retry — the next call re-pins to the new head |
Stale empty branches at main's head | 504-orphaned forks from a timed-out load --from; eventually block writes | List branches, find ones at main's graph_commit_id, omnigraph branch delete --config X <name> |
omnigraph schema apply / init on a cluster-managed graph | refused — bypasses the cluster ledger | Evolve cluster graphs via omnigraph cluster apply --config .; schema apply/init are for a non-cluster store |
omnigraph optimize against a table with a Blob property | table is skipped, not failed (Lance blob-v2 compaction bug) | Expected — --json reports it under skipped; non-blob tables still compact |
@unique on a [List]/Blob column | load now errors loudly (was silently un-enforced before #160) | Use @unique only on scalar columns (and composite @unique(a, b), now keyed as a true tuple) — uniqueness needs a type that reduces to a scalar key |
Deep Dives
references/cluster.md— cluster-mode declarative deployments: cluster.yaml, the validate/import/plan/apply loop, approval-gated deletes,--clusterserving, the two-file contract, recovery
For anything beyond the basics, load the relevant reference file. Each is self-contained — load only what you need.
| Reference | When to load |
|---|---|
| `references/schema.md` | Editing .pg files, running schema plan/apply, renaming types, backfilling required fields |
| `references/queries.md` | Writing or linting .gq files, search functions, aggregations, multi-hop patterns |
| `references/data.md` | Choosing between mutate and load (required --mode, --from to fork a review branch); branch review workflow; destructive ops |
| `references/remote-ops.md` | Operating against a remote/CloudFront-fronted graph: 504 verification ritual, version drift, fork-branch 504 fingerprints, append-only retry safety, operator --server/login targeting |
| `references/search.md` | Embeddings, @embed, vector/text ranking, scope-then-rank pattern |
| `references/aliases.md` | Defining aliases for agents, structured output, JSON args |
| `references/stored-queries.md` | Server-side stored-query registry: declared in cluster.yaml, omnigraph queries validate/list, GET /graphs/{id}/queries + POST /graphs/{id}/queries/{name}, invoke_query Cedar gating |
| `references/server-policy.md` | Starting the HTTP server, routes, bearer auth, Cedar policy gating, multi-graph mode |
| `references/commands.md` | snapshot, export, commit list/show, addressing & resolution |
| `references/migrations.md` | Migrating a pre-0.7.0 setup, or you hit an old config/command/flag/route/error and need its current form |
Aliases & Agent Automation
Contents
- What an alias is
- Operator alias schema
- Args binding & JSON-first parsing
- Default to structured output
- Alias naming convention
- Secrets don't belong in aliases
- Example alias set
- Invocation patterns
How to wire Omnigraph operations for agents and scripts.
What an alias is
An operator alias decouples a stable operation name from its implementation, so an agent calling omnigraph alias signal … keeps working as the query evolves. Aliases live in ~/.omnigraph/config.yaml and are personal bindings to a stored query on a named server — they carry no query content; the stored query in the cluster catalog is the team's contract.
# ~/.omnigraph/config.yaml
aliases:
triage:
server: intel-dev # an entry under servers:
graph: spike # optional (multi-graph servers)
query: weekly_triage # the STORED query's name — never a file
args: [since] # positional args → params, in order
params: { limit: 20 } # fixed defaults; positionals/--params win
format: tableomnigraph alias triage 2026-06-01
# → POST <intel-dev>/graphs/spike/queries/weekly_triage with the keyed credentialAlias vs stored query. The alias is yours (a personal name + defaults); the stored query it points at is the team's — declared incluster.yaml, type-checked and served by the cluster (GET /graphs/<id>/queries,POST /graphs/<id>/queries/<name>, gated byinvoke_query). See `stored-queries.md`.
Operator Alias Schema
aliases:
<alias-name>:
server: <server-name> # an entry under servers: in ~/.omnigraph/config.yaml
graph: <graph-id> # optional: for multi-graph servers
query: <stored-query> # the stored query's NAME (never a file path)
args: [<name1>, <name2>] # positional CLI args → named params, in order
params: { <k>: <v> } # fixed default params; positionals / --params win
format: table|kv|csv|jsonl|json # optional: output formatDispatch with omnigraph alias <name> [args] — one subcommand for read and write stored queries (a mutation alias is double-gated by invoke_query + change). Aliases live in their own namespace, so one can never shadow or be shadowed by a built-in verb.
args bind to query parameters
If args: [slug, name, age], then:
omnigraph alias foo sig-bar "Some Name" 29...maps to {"slug":"sig-bar","name":"Some Name","age":29}.
Args are JSON-first
Each arg is parsed as JSON first, then falls back to string:
29→ integer"29"→ stringtrue→ booleanAlice→ string (JSON parse fails, falls back){"x":1}→ object
Explicit --params '{...}' wins on key conflict.
Default to Structured Output
For scripts and agents, prefer jsonl or json; table is for humans. Set a default in ~/.omnigraph/config.yaml:
defaults:
output: jsonlOr per-alias (format: jsonl), or per-call (--format jsonl).
When to use which
- `jsonl` — one JSON object per line, first line is metadata; streams; ideal for agents
- `json` — pretty-printed JSON array; smaller results; human-readable
- `kv` —
key: valueper line; good for single-row lookups - `csv` — for spreadsheets or line-count-heavy analysis
- `table` — default human view; don't use in automation
Alias Naming Convention
Short, hyphenated, matches the conceptual operation:
signal,pattern,element— single lookup (typical pair withformat: kv)signals,patterns,elements— listsignal-patterns,pattern-signals— traversalsadd-signal,link-forms-pattern— mutations
Secrets Don't Belong in Aliases
Credentials never live in an alias or any config file. For remote servers, omnigraph login <server> stores the bearer token in ~/.omnigraph/credentials (0600); for S3-backed storage, AWS creds go in .env.omni. Aliases should only contain query names and parameter bindings — never tokens, passwords, or API keys.
Example Alias Set
# ~/.omnigraph/config.yaml
servers:
intel-dev: { url: https://graph.example.com }
aliases:
# Lookups (kv format for single-row readability)
signal: { server: intel-dev, graph: spike, query: get_signal, args: [slug], format: kv }
pattern: { server: intel-dev, graph: spike, query: get_pattern, args: [slug], format: kv }
# Lists
signals: { server: intel-dev, graph: spike, query: recent_signals }
# Traversals
pattern-signals: { server: intel-dev, graph: spike, query: pattern_signals, args: [slug] }
# Mutations (stored mutation; invoke_query + change)
add-signal: { server: intel-dev, graph: spike, query: add_signal, args: [slug, name, brief, stagingTimestamp, createdAt, updatedAt] }
link-forms-pattern: { server: intel-dev, graph: spike, query: link_signal_forms_pattern, args: [signal, pattern] }Each query: names a stored query the cluster serves — declare them in cluster.yaml and cluster apply first (see `stored-queries.md`).
Invocation Patterns
# Invoke an alias (read or write — the bound stored query decides)
omnigraph alias signal sig-kimi-k25
omnigraph alias add-signal sig-new "Name" "Brief" \
2026-04-14T00:00:00Z 2026-04-14T00:00:00Z 2026-04-14T00:00:00Z
# Override output format
omnigraph alias signals --format jsonl
# Explicit --params (wins over positional args on key conflict)
omnigraph alias signal --params '{"slug":"sig-override"}'The alias subcommand carries --params/--params-file, --format/--json, and --config; the server, graph, and stored-query name come from the binding. For a different server/graph or a branch read, call query/mutate directly.
Cluster Mode — Declarative Deployments
Contents
- The model
- The loop (validate → import → plan → apply → serve)
- The config contract (
cluster.yamlvs~/.omnigraph/config.yaml) - Serving (
--cluster, config-free bucket boot) - Recovery cheat-sheet
The cluster control plane (omnigraph >= 0.7.0) manages a whole deployment — graphs, schemas, stored queries, Cedar policies — as declared files in one directory, converged Terraform-style. It is the only way to serve a graph (the server is cluster-only); the data-plane operations in the other references work against the cluster's graphs unchanged.
The model
company-brain/
├── cluster.yaml # the deployment: graphs, schemas, queries, policies
├── schema.pg
├── queries/*.gq
├── *.policy.yaml
├── graphs/<id>.omni # DERIVED — created by apply, never by hand (gitignore)
└── __cluster/ # ledger + catalog + approvals — local state (gitignore)# cluster.yaml
version: 1
# storage: s3://my-bucket/clusters/company-brain # optional — put ledger,
# catalog, and graph roots on S3 object storage (default: this folder)
state: { backend: cluster, lock: true }
graphs:
knowledge:
schema: schema.pg
queries: queries/ # the .gq files ARE the declaration — every `query <name>` registers
policies:
base: { file: base.policy.yaml, applies_to: [knowledge] } # or [cluster] for server-levelqueries also accepts a file list ([a.gq, b.gq]) or a fine-grained name: { file: ... } map. Discovery is loud: unparseable files and duplicate names across files fail validation.
The loop (memorize this)
omnigraph cluster validate --config . # parse + typecheck everything
omnigraph cluster import --config . # one-time: create the state ledger
omnigraph cluster plan --config . # preview — REQUIRED reading before apply
omnigraph cluster apply --config . --as <you> # converge (idempotent)
omnigraph-server --cluster . --bind 127.0.0.1:8080 --unauthenticated # serve (local dev)- `apply` creates graphs at
graphs/<id>.omni— there is no separate
omnigraph init in cluster mode.
- Schema changes: edit the
.pg,planshows the engine's real migration
steps (add_property, drop_property [soft], unsupported: …), apply migrates the live graph. Soft drops only — data-loss migrations are not reachable from cluster apply (prior versions retain dropped columns).
- Applied = serving on the next server restart. No hot reload.
- `storage: s3://bucket/prefix` (optional) puts the entire cluster — state
ledger, lock, content-addressed catalog, recovery sidecars, approval artifacts, and the derived graph roots (<storage>/graphs/<id>.omni) — on S3-compatible object storage. The ledger CAS uses S3 conditional writes and the lock becomes genuinely cross-machine. Absent, everything defaults to the config directory (byte-compatible with pre-existing clusters). Credentials come from the standard AWS_* env contract, never cluster.yaml.
- `--as <actor>` attributes every run (sidecars, audit, engine commits).
Defaults from your operator config's operator.actor; required for approve.
- Destructive changes are gated: removing a graph from
cluster.yaml
blocks with approval_required until omnigraph cluster approve graph.<id> --config . --as <you> records a digest-bound approval. Any config/state drift after approving invalidates it.
- Drift:
cluster refreshre-observes live graphs and marks out-of-band
changes drifted; the next apply converges them back to the declaration.
- Data is NOT cluster's job: rows flow through
omnigraph load / mutate
against the derived roots, with branches as usual.
The config contract (do not blur this)
| File | Owns | Read by |
|---|---|---|
cluster.yaml | the deployment: graph set, schemas, stored queries, policy bindings, storage | cluster commands; the --cluster server |
~/.omnigraph/config.yaml | per-operator: identity (operator.actor), named servers:, output defaults, personal aliases | data-plane CLI commands (tokens live in ~/.omnigraph/credentials via omnigraph login) |
Cluster commands read the operator config for exactly one thing: the actor default when --as is omitted (--as > operator.actor). A --cluster server reads it for nothing — boot from cluster state XOR the operator file, never a merge. Address a cluster-managed graph's data directly with --store <storage>/graphs/<id>.omni, or via --server/aliases against a serving instance — that is ergonomics, not coupling.
Serving
omnigraph-server --cluster <dir> is exclusive (cannot combine with a URI, --target, or --config), always multi-graph (/graphs/{id}/...), and fail-fast: missing/pending/tampered state refuses boot with a remedy. Every declared query is exposed (GET /graphs/<id>/queries, POST /graphs/<id>/queries/<name>); Cedar bundles attach via applies_to (cluster → server-level gate incl. graph_list; graph.<id> → that graph's gate incl. invoke_query). Bearer tokens and bind stay process-level (env/flags).
Config-free serving. --cluster also accepts the storage-root URI directly — omnigraph-server --cluster s3://bucket/prefix boots from the applied revision on the bucket with no checkout of the config repo. The ledger and catalog on the bucket are the whole deployment artifact; policy bundles serve as digest-verified content from the catalog. The preferred container shape is bucket, no volume (AWS ECS / Railway recipes in the omnigraph repo's docs/user/deployment.md). For a mounted config directory instead, OMNIGRAPH_CLUSTER=<dir> works and the image ships the CLI for in-container cluster apply.
Recovery cheat-sheet
| Symptom | Fix |
|---|---|
| Apply crashed mid-run | run cluster apply again — sidecars + sweep reconcile |
| Held lock | cluster status (shows lock id) → cluster force-unlock <LOCK_ID> --config . |
Lost/corrupt state.json | cluster import rebuilds from config + live graphs, then apply |
| Server refuses to boot | the error names its remedy (usually cluster refresh + apply, restart) |
approval_stale warning | re-run cluster approve — the plan changed since you approved |
Full reference: the omnigraph repo's docs/user/clusters/index.md (operator guide) and docs/user/clusters/config.md (every key, flag, and diagnostic).
Reference Commands
Contents
- Inspect state (snapshot, export)
- Branches · commits · graphs
- Schema · lint · embed · init
- Load (bulk JSONL)
- Query / mutate
- Maintenance (optimize, cleanup)
- Stored queries
- Operator config & credentials
- Config resolution order
- Output formats · health check
- Cluster control plane
Commands you'll reach for but don't need best-practice rules around. Quick syntax reference.
Inspect State
snapshot — tables + row counts
omnigraph snapshot $REPO --branch main --jsonReturns the manifest: all node/edge tables with row counts and versions. Use this to verify a load succeeded or to see what types exist.
export — full JSONL dump
omnigraph export $REPO --branch main > graph.jsonlStreams all nodes and edges as JSONL. The right tool for large-snapshot inspection. Don't try to page through the whole graph with read queries.
Filter by type:
omnigraph export $REPO --branch main --type Signal > signals.jsonlBranches
omnigraph branch create --from main <branch-name> --store $REPO
omnigraph branch list --store $REPO
omnigraph branch merge <branch-name> --into main --store $REPO
omnigraph branch delete <branch-name> --store $REPOAll support --json.
Commits (History)
omnigraph commit list $REPO --branch main
omnigraph commit show $REPO <commit-id>Inspect graph history. Useful for "what changed between these two points" investigation.
Graphs (multi-graph servers)
omnigraph graphs list --config X --jsonLists the graphs a multi-graph server serves. Remote servers only (rejects local URIs); the server must expose GET /graphs via server.policy.file. See references/server-policy.md.
Schema
omnigraph schema plan --schema next.pg $REPO --json
omnigraph schema apply --schema next.pg $REPOSee references/schema.md for the full workflow.
Lint
omnigraph lint --schema schema.pg --query queries/foo.gq --json
# or against a live repo:
omnigraph lint --query queries/foo.gq $REPO --jsonlint is the single query-validation command. See references/queries.md.
Embed
omnigraph embed --seed embed-config.yaml # fill missing
omnigraph embed --seed embed-config.yaml --reembed_all # regenerate all
omnigraph embed --seed embed-config.yaml --clean # delete
omnigraph embed --seed embed-config.yaml --select "Type:field=value"See references/search.md.
Init
omnigraph init --schema schema.pg $REPOCreates a new graph at $REPO with the given schema. Declare the deployment in a cluster.yaml (see references/cluster.md).
Strict by default (v0.6.0+): init against a URI that already holds schema files errors with AlreadyInitialized instead of silently overwriting. Use omnigraph init --force to re-init deliberately. --force only skips the schema-file preflight — it does not purge existing Lance datasets.
Note: init does not accept --json. Drop the flag if you see unexpected argument --json.
Load (bulk JSONL)
# bare load: operates on an existing branch (default main); --mode is required
omnigraph load --data seed.jsonl --mode merge $REPO
# --from forks a missing branch from <base>, then loads onto it (one-shot review branch)
omnigraph load --data delta.jsonl --branch feature-x --from main --mode merge $REPO--mode is required (no default): merge, append, or overwrite. load works against local and remote URIs. See references/data.md.
Query / Mutate
omnigraph query get_signal --query queries/signals.gq --params '{"slug":"sig-foo"}' # ad-hoc file; <name> is positional
omnigraph query get_signal --server intel-dev --params '{"slug":"sig-foo"}' # served stored query by name
omnigraph mutate add_signal --query queries/mutations.gq --params '{"slug":"sig-foo",...}'With aliases:
omnigraph alias signal sig-foo
omnigraph alias add-signal sig-foo "Name" "Brief" 2026-04-14T00:00:00Z 2026-04-14T00:00:00Z 2026-04-14T00:00:00Zqueryandmutatealso accept inline source via-e/--query-string '<gq>'instead of--query <file>.
Maintenance: Optimize & Cleanup (v0.6.1)
optimize — non-destructive Lance compaction
omnigraph optimize $REPO --jsonCompacts fragments and reclaims deleted-row space. Non-destructive — safe to run any time. Skips tables with a `Blob` property (Lance blob-v2 compaction decode bug); skipped tables are reported in the skipped field of --json output and in logs. Non-blob tables compact normally. Blob-table fragment count won't shrink until the upstream Lance fix lands — reads/writes are unaffected.
cleanup — destructive version GC
omnigraph cleanup $REPO --keep 5 --older-than 7d --confirmGarbage-collects old table versions, dropping time-travel reachability for anything pruned. Destructive — requires --confirm. Duration units for --older-than: s, m, h, d, w. Also reconciles orphaned per-table forks left by an interrupted branch delete.
Stored Queries (v0.6.1)
omnigraph queries validate # type-check the stored-query registry vs the live schema (offline; exits non-zero on drift)
omnigraph queries list # list registry query names, MCP exposure, and typed paramsvalidate opens the addressed graph and type-checks every applied stored query against the live schema — catches drift without restarting the server. list prints that graph's registry. Address the graph with --store <uri> or a positional URI. Distinct from lint (which validates a single .gq file). See references/stored-queries.md.
Operator Config & Credentials
echo "$TOKEN" | omnigraph login <server> # store a bearer token in ~/.omnigraph/credentials (0600)
omnigraph logout <server> # remove it (idempotent)The operator config and ~/.omnigraph/credentials are auto-discovered — there is no flag to point at them. $OMNIGRAPH_HOME relocates the ~/.omnigraph directory (mainly for test isolation), and an absent file is just an empty layer (zero-config). Separately, $OMNIGRAPH_CONFIG stands in for the --config flag — which targets the cluster directory / server config, never the operator config. See SKILL.md → The two config surfaces.
Addressing a Graph
How the CLI resolves which graph a data command (query, mutate, load, branch, …) runs against. A remote is addressed with --server (a bare http(s):// URL is not a graph address).
Precedence (highest first):
1. `--store <uri>` or a positional `file://`/`s3://` URI — direct storage access (bypasses any server; no catalog, so stored-query names don't resolve). --store is exclusive with a positional URI and with --server. 2. `--server <name|url>` (+ --graph <id> for a multi-graph server) — served/remote. A name resolves from servers: in ~/.omnigraph/config.yaml; a literal http(s):// URL also works. 3. `--profile <name>` (or $OMNIGRAPH_PROFILE) — a named scope bundle from profiles: in the operator config (binds one of server/cluster/store + a default graph). 4. Operator defaults — defaults.server + defaults.default_graph, or defaults.store for a zero-flag local scope (mutually exclusive with defaults.server).
Control-plane commands use --config <dir> (cluster); maintenance against a cluster-managed graph uses --cluster <dir|s3://> --graph <id>. Each command declares a capability — any / served / direct / control / local — shown in omnigraph --help; mis-addressing (e.g. --server on a direct verb, or a remote URI to optimize) fails loudly.
For query source (query/mutate):
1. `--query <file>` or `-e/--query-string '<gq>'` — exactly one (operator aliases are invoked via the separate alias subcommand) 2. Relative --query paths resolve through `query.roots` in config
For params:
1. Explicit `--params '{...}'` wins on key conflict 2. Positional alias args map to alias args list
Output Formats
--format <fmt> on query/mutate:
table(default) — human-readablekv—key: valueper line; good for single rowscsv— comma-separatedjsonl— NDJSON, one per line, with metadata line firstjson— pretty JSON array
For admin commands (branch, commit, schema, policy): use --json for structured output, otherwise human text.
Health Check
curl http://127.0.0.1:8080/healthzReturns 200 OK if the server is up.
Cluster Control Plane (omnigraph >= 0.7.0)
omnigraph cluster validate --config <dir> # parse + typecheck the declaration
omnigraph cluster import --config <dir> # one-time: create the state ledger
omnigraph cluster plan --config <dir> [--json] # preview (schema changes show migration steps)
omnigraph cluster apply --config <dir> --as <actor> # converge; idempotent
omnigraph cluster approve <resource> --config <dir> --as <actor> # gate destructive changes (graph deletes)
omnigraph cluster status --config <dir> [--json] # read the ledger (read-only)
omnigraph cluster refresh --config <dir> # re-observe live graphs; flags drift
omnigraph cluster force-unlock <LOCK_ID> --config <dir> # clear a crashed run's lock (exact id from status)Topology rule: omnigraph schema apply and omnigraph init refuse a cluster-managed graph — in a cluster their jobs belong to cluster apply. Data commands (load, mutate, branches) work either way — point them at the derived root (<dir>/graphs/<id>.omni, or <storage>/graphs/<id>.omni for an S3-backed cluster). See references/cluster.md.
Data Changes & Branches
Contents
- Choose the right write command
mutate— single editsload— bulk JSONL (--mode,--from)- Branches: review before merge
- Destructive ops go through a branch
- Branch commands
- Inspecting state after changes
How to modify data safely in Omnigraph.
Choose the Right Write Command
load is the one bulk-JSONL command — local or remote, against any existing branch, with a required --mode. mutate is for single typed edits.
| Task | Command | Why |
|---|---|---|
| Add/update a single entity | mutate with a named mutation | typechecked, parameterized, auditable |
Bulk upsert by @key | load --mode merge | preserves rows not in the file |
| Additive-only bulk | load --mode append | fails on key collision |
| Clean-slate reseed | load --mode overwrite | destructive — wipes the branch |
| Bulk load onto a fresh review branch | load --from main --mode merge --branch <name> | forks <name> from main, loads onto it, leaves it for review |
`--mode` is required — there is no default. Overwrite is destructive, so
the CLI never picks a mode for you.
>
Local and remote are one command. load works against a local repo URI(writing storage directly) and a remote omnigraph-server endpoint (theserver orchestrates the write and publishes one atomic commit). See
`references/remote-ops.md` for remote-specific concerns
(504 handling, write-verification ritual).
mutate — Single Edits
Goes through the running server (the configured default graph, or an alias):
omnigraph mutate add_signal \
--query mutations.gq \
--params '{"slug":"sig-foo","name":"Foo","brief":"...","stagingTimestamp":"2026-04-14T00:00:00Z","createdAt":"2026-04-14T00:00:00Z","updatedAt":"2026-04-14T00:00:00Z"}'Or via an alias:
omnigraph alias add-signal sig-foo "Foo" "..." 2026-04-14T00:00:00Z 2026-04-14T00:00:00Z 2026-04-14T00:00:00ZPrefer mutate for interactive edits, mutations called from agents, and anything you want typechecked at call time.
load — Bulk JSONL
JSONL format:
{"type":"Signal","data":{"id":"sig-foo","slug":"sig-foo","name":"Foo","brief":"...","stagingTimestamp":"2026-04-14T00:00:00Z","createdAt":"2026-04-14T00:00:00Z","updatedAt":"2026-04-14T00:00:00Z"}}
{"edge":"FormsPattern","from":"sig-foo","to":"pat-bar","data":{}}- Nodes:
{"type":"<NodeType>","data":{...props...}}—idequalsslug - Edges:
{"edge":"<EdgeType>","from":"<src_slug>","to":"<dst_slug>","data":{...edge_props...}}
Load command:
omnigraph load --data seed.jsonl --mode merge s3://my-bucket/repos/spike-intel--from <base> forks a missing --branch from <base> before loading (the one-shot review-branch flow below). Without --from, the target --branch (default main) must already exist.
--mode semantics
- `overwrite` (destructive) — replaces every node/edge table on the branch with the file's contents. Staged: the loader validates node/edge constraints, referential integrity, and edge cardinality before any data moves, so a bad file fails before touching the branch. Safe on a first load; risky afterward. Don't run it against
mainin production without a branch backup path. - `merge` (upsert) — for each row, insert if
@keyis new, update if it exists. Rows not in the file are preserved. The safe default for incremental bulk updates. - `append` (strict insert) — fails on key collision. Use when you're certain every row is new.
merge does NOT recompute embeddings
If you change seed rows that feed into @embed("source") via load --mode merge, the source field updates but the embedding stays stale.
Fix: run omnigraph embed --reembed_all after, or use load --mode overwrite once (which re-triggers embedding on load).
overwrite is destructive
Wipes the entire branch's data for every node and edge type. Use only for:
- First-time seed
- Intentional full reseed on a feature branch
- Recovery scenarios
Never on main without a branch backup.
Branches: Review Before Merge
Branches exist for data review, not schema changes. Schema goes straight to main via plan + apply.
The review loop
REPO=s3://my-bucket/repos/spike-intel
# 1. Create feature branch from main
omnigraph branch create --from main staging-2026-04-14 --store $REPO
# 2. Load delta onto the branch (merge mode is typical for review)
omnigraph load --data delta.jsonl --branch staging-2026-04-14 --mode merge $REPO
# 3. Verify on the branch (reads can target --branch or --snapshot)
omnigraph query recent_signals --query queries/signals.gq --branch staging-2026-04-14 --store $REPO
# 4. Merge to main when happy
omnigraph branch merge staging-2026-04-14 --into main --store $REPO
# 5. Optionally delete the branch
omnigraph branch delete staging-2026-04-14 --store $REPOFork a branch in one shot with --from
- Bare
loadoperates on an existing branch (defaultmain). load --from main --branch <name>forks<name>frommain, loads onto it, and leaves it for review — the whole review-branch flow in one command.
Use --from for anything you want reviewed before it touches main.
Keep branches short-lived
Long-lived branches compound merge risk. The usual flow is: create → load → verify → merge → delete, all in the same session. A week-old feature branch is a yellow flag.
Schema apply blocks non-main branches
omnigraph schema apply rejects the request if any non-main branches exist. Merge or delete them first. This is enforced — it's not just a guideline.
Destructive Ops Go Through a Branch
For any bulk load that could disrupt downstream queries (overwriting a heavily-referenced node type, removing edges en masse, reseeding a core table), use a feature branch:
omnigraph load --data risky.jsonl --branch recovery-2026-04-14 \
--from main --mode overwrite $REPO
# inspect, diff, verify reads
omnigraph branch merge recovery-2026-04-14 --into main --store $REPOBranch Commands (quick reference)
omnigraph branch create --from main <branch-name> --store $REPO
omnigraph branch list --store $REPO
omnigraph branch merge <branch-name> --into main --store $REPO
omnigraph branch delete <branch-name> --store $REPOAll support --json for automation-friendly output. Address the graph with a positional file:///s3:// URI (shown), --store <uri>, or --server <name>.
Inspecting State After Changes
omnigraph snapshot $REPO --branch main --json # tables + row counts
omnigraph export $REPO --branch main > graph.jsonl # full JSONL dump
omnigraph commit list $REPO --branch main --json # historyexport is the right tool for large-snapshot inspection — don't try to page through the whole graph with read queries.
Cluster note: everything in this file applies unchanged in cluster
deployments — the control plane owns schema/queries/policies; rows, loads,
and branches stay on the data plane against the derived graph roots
(<dir>/graphs/<id>.omni, or<storage>/graphs/<id>.omnifor an S3-backed
cluster).
Migration & Deprecations (pre-0.7.0 → 0.7.0)
The rest of this skill teaches the current 0.7.0 surface only. Consult this page solely when you meet an old config file, command, flag, route, or error and need its current form. Pre-0.7.0 spellings keep working as deprecated aliases (they print a warning) unless marked removed.
Config files
| Before (pre-0.7.0) | Now (0.7.0) |
|---|---|
omnigraph.yaml (one combined file) | `cluster.yaml` (team deployment) + `~/.omnigraph/config.yaml` (operator) |
cli.actor | operator.actor |
cli.graph / server.graph | defaults.default_graph (+ defaults.server) |
targets: / target: | graphs: / graph: |
omnigraph init scaffolds omnigraph.yaml | init scaffolds nothing — start a cluster.yaml from `cluster.md` |
- `omnigraph.yaml` is fully removed in 0.7.0 — no CLI command or server reads it, and there is no `config migrate`. Move team settings to
cluster.yamland personal settings (identity,servers:,defaults:,aliases:) to~/.omnigraph/config.yamlby hand.
CLI addressing (RFC-011)
| Before | Now |
|---|---|
--target <name> | removed — use `--server <name\ |
positional http(s):// URL → a server | removed — address a remote with --server <url> |
--as on a served (remote) write | no-op — the server resolves the actor from the bearer token (--as applies to direct --store writes) |
--cluster-graph <id> | removed — `--cluster <dir\ |
query/mutate --name <q> + positional graph URI / --uri | removed — the query name is the positional (omnigraph query <name>): a bare <name> invokes a served stored query (kind-asserted), --query/-e is the ad-hoc lane. Address the graph via --server/--store/--profile (not a positional URI on query/mutate) |
Server boot & schema (RFC-011)
| Before | Now |
|---|---|
omnigraph-server <URI> / --config omnigraph.yaml / --target / single-graph flat routes | removed — the server is cluster-only: `omnigraph-server --cluster <dir\ |
omnigraph schema apply on a cluster-managed graph | refused — evolve cluster graphs via cluster apply (the ledger). schema apply still works on a non-cluster store or via --server |
policy … / queries validate via --config omnigraph.yaml | `policy validate\ |
CLI verbs
| Before | Now |
|---|---|
omnigraph ingest … | omnigraph load --from main --mode merge … |
omnigraph read | omnigraph query |
omnigraph change | omnigraph mutate |
omnigraph query lint / query check | omnigraph lint |
omnigraph query --alias <n> / mutate --alias <n> | omnigraph alias <n> (dedicated subcommand; the --alias flag was removed) |
HTTP routes
| Before | Now |
|---|---|
POST /ingest | POST /load |
POST /read | POST /query |
POST /change | POST /mutate |
The old routes remain as deprecated aliases (retained indefinitely), carrying Deprecation: true + Link: <successor> response headers.
Server token resolution
| Before | Now |
|---|---|
graphs.<name>.bearer_token_env in omnigraph.yaml | omnigraph login <server> → ~/.omnigraph/credentials, or OMNIGRAPH_TOKEN_<NAME> |
The client bearer token now comes only from OMNIGRAPH_TOKEN_<NAME> or the credentials file — the omnigraph.yaml bearer_token_env chain is gone with the file.
Older removals (still worth knowing)
- The transactional Run state machine, its
/runsroutes, and therun_publish/run_abortCedar actions were removed in v0.4.0. Writes publish directly — useGET /commitsfor history and thechangeaction for write gating;/runsreturns 404.
Query Authoring & Linting
Contents
- File organization
- Linting
- Parameterization
- Query structure
- Search functions
- Aggregations
- Filter operators
- Mutations
- Naming convention
- Aliases over raw queries
Writing .gq query files in Omnigraph.
File Organization
- One
.gqfile per primary node type (signals.gq,patterns.gq,elements.gq) - One
mutations.gqfile for all insert/update/delete queries - Put query files in
queries/— cluster mode discoversqueries/*.gqautomatically
Linting
omnigraph lint --schema schema.pg --query queries/signals.gqOr (lint against a live repo):
omnigraph lint --query queries/signals.gq s3://bucket/repoLint returns:
"status": "ok"— all queries passed"errors": N— count of type errors (exit 1 when nonzero)"warnings": N— count of drift warnings
Run lint after every .gq or .pg edit. Wire into precommit.
Parameterization
Always declare typed parameters
query get_signal($slug: String) {
match { $s: Signal { slug: $slug } }
return { $s.slug, $s.name }
}Never string-interpolate values into query bodies. Pass them via --params:
omnigraph query get_signal --query signals.gq --params '{"slug":"sig-foo"}'The compiler typechecks parameter values against declared types.
For one-off/ad-hoc execution, pass the query inline instead of a file with-e/--query-string(v0.6.0+):omnigraph query -e 'query q($slug: String){ match { $s: Signal { slug: $slug } } return { $s.name } }' --params '{"slug":"sig-foo"}'(andomnigraph mutate -e '...').-eis mutually exclusive with--query <file>— exactly one of the two is required. (Operator aliases are invoked via the separateomnigraph alias <name>subcommand.)
Query Structure
Match → Return → Order → Limit
query recent_signals() {
match {
$s: Signal
}
return { $s.slug, $s.name, $s.stagingTimestamp }
order { $s.stagingTimestamp desc }
limit 50
}Edge traversal (lowerCamelCase)
Schema edges are PascalCase; traversal uses lowerCamelCase:
match {
$s: Signal { slug: $slug }
$s formsPattern $p // edge FormsPattern: Signal -> Pattern
}Multi-hop
Chain traversal clauses:
query friends_of_friends($name: String) {
match {
$p: Person { name: $name }
$p knows $mid
$mid knows $fof
}
return { $fof.name }
}Reverse traversal
Flip the subject/object:
query employees_of($company: String) {
match {
$c: Company { name: $company }
$p worksAt $c
}
return { $p.name }
}Negation
query orphan_signals() {
match {
$s: Signal
not { $s formsPattern $_ }
}
return { $s.slug }
}Search Functions
Text search
match {
$d: Doc
search($d.title, $q) // full-text on @index'd String
}match {
$d: Doc
fuzzy($d.title, $q, 2) // fuzzy match, max 2 edits
}match {
$d: Doc
match_text($d.body, $q) // phrase match
}Vector/ranking (require limit)
query vector_search($q: Vector(3072)) {
match { $d: Doc }
return { $d.slug, $d.title }
order { nearest($d.embedding, $q) }
limit 10
}nearest, bm25, and rrf are ranking operators, not filters. Every query using them must end with limit N — omitting it is a compile error.
Hybrid (reciprocal rank fusion)
query hybrid_search($vq: Vector(3072), $tq: String) {
match { $d: Doc }
return { $d.slug, $d.title }
order { rrf(nearest($d.embedding, $vq), bm25($d.title, $tq)) }
limit 10
}Aggregations
query friend_counts() {
match {
$p: Person
$p knows $f
}
return {
$p.name
count($f) as friends
}
order { friends desc }
limit 20
}Supported: count, sum, avg, min, max. Grouping is implicit on non-aggregated return fields.
Filter Operators
=, !=, >, <, >=, <=, contains
match {
$p: Person
$p.age > 30
$p.name contains "Al"
}Mutations
No top-level `mutation { ... }` wrapper. Agents trained on GraphQL reflexively writemutation { insert T { ... } }— that fails the parser at character 1 withparse error: expected query_file. Every executable block in a.gqfile is a namedquery; the body's verb (insert/update/delete) determines whether it's a write. Dispatch viaomnigraph mutate(notquery).
Insert
query add_signal($slug: String, $name: String, $brief: String,
$stagingTimestamp: DateTime, $createdAt: DateTime, $updatedAt: DateTime) {
insert Signal {
slug: $slug,
name: $name,
brief: $brief,
stagingTimestamp: $stagingTimestamp,
createdAt: $createdAt,
updatedAt: $updatedAt
}
}Every non-nullable property must be provided. Lint catches missing ones as:
error: T12: insert for 'Signal' must provide non-nullable property 'brief'Insert edge
query link_signal_forms_pattern($signal: String, $pattern: String) {
insert FormsPattern { from: $signal, to: $pattern }
}Edge data block is {} if the edge has no properties — just specify from and to slugs.
Update
query retitle_signal($slug: String, $new_title: String) {
update Signal set { name: $new_title } where slug = $slug
}Delete
query remove_signal($slug: String) {
delete Signal where slug = $slug
}Multi-statement
query add_and_link($slug: String, $pattern: String, $createdAt: DateTime, $updatedAt: DateTime) {
insert Signal { slug: $slug, name: $slug, brief: $slug,
stagingTimestamp: $createdAt, createdAt: $createdAt, updatedAt: $updatedAt }
insert FormsPattern { from: $slug, to: $pattern }
}There's no upsert keyword at the query level — use load --mode merge for bulk upsert.
Insert/update-only OR delete-only (the D₂ rule). A single mutation query may contain inserts and updates, or deletes — never both. Mixing adeletewith aninsert/updatein the same query is rejected at parse time. (Inserts/updates go through a staged two-phase publish; deletes inline-commit — omnigraph doesn't yet use Lance's two-phase delete API (it shipped in Lance 7.0.0 but isn't wired in) — so they can't share one atomic statement.) Split a delete-then-insert into two separate mutations.
Date and DateTime values
Date format is asymmetric between mutate (parameter values) and load (JSONL):
| Path | Date | DateTime |
|---|---|---|
mutate --params | ISO string "2026-04-29" | ISO string "2026-04-29T10:00:00Z" |
load JSONL | Integer days since epoch 20572 | ISO string "2026-04-29T10:00:00Z" |
Compute integer days form for a given date d:
(d - datetime.date(1970, 1, 1)).days # d is the date you're loading, not today()This asymmetry is one of the most common silent type errors when bulk-loading data prepared for one path through the other.
Naming Convention
verb_object:
get_signal,recent_signals,search_signalssignal_patterns,signal_elements(traversal queries)add_signal,link_signal_forms_pattern(mutations)
Aliases Over Raw Queries
For anything an agent or script will call repeatedly, define an operator alias. See references/aliases.md.
Remote Graph Operations
Contents
- What's different about remote
- Verify after every write
- 504 Gateway Timeout
- Fork-branch 504 fingerprint
- Targeting a remote graph (
--server,login) - Version drift /
sync_branch() manifest_conflict409- 429 Too Many Requests
- Duplicate risk on blind retry
- Reading large schemas safely
- Prevention checklist
When the graph URI is a remote endpoint (omnigraph-server behind ALB / CloudFront, bearer-authenticated) instead of a local S3 path, several CLI behaviors change in ways the local-storage workflow never exposes. This reference covers the failures and operational rituals specific to remote graphs.
What's different about remote
A remote graph runs server-side. Every write executes on the server — staged per touched table, then published atomically as a single manifest commit guarded by a compare-and-swap on expected table versions — and is gated by a connection-level idle timeout (CloudFront defaults to ~30s). There is no separate "run" object to poll — write status is implied by the HTTP response (and verifiable via commit list). The local CLI is a thin client; it never sees the commit happen, only the HTTP response. That asymmetry is the root of every gotcha below.
| Local repo | Remote repo |
|---|---|
| CLI writes S3 directly | Server executes the write, publishes one atomic manifest commit |
| No connection timeout | ~30s idle timeout (CloudFront) |
| No admission control | Per-actor 429 + Retry-After on writes |
load writes S3-backed storage directly | load is server-orchestrated — same command, one atomic commit |
| CLI exit code is authoritative | CLI exit code can lie — verify via commit list |
Verify after every write
The CLI's exit code is not authoritative on remote graphs. The proxy can drop a response after the server has already committed. Always verify by comparing main's head:
HEAD_BEFORE=$(omnigraph commit list --config X --branch main --json | jq -r '.commits[0].graph_commit_id')
# … run your load / mutate …
HEAD_AFTER=$(omnigraph commit list --config X --branch main --json | jq -r '.commits[0].graph_commit_id')
if [[ "$HEAD_BEFORE" != "$HEAD_AFTER" ]]; then
echo "landed"
else
echo "did NOT land — safe to retry"
fiFor a load --from that forks a review branch, also compare the new branch head's graph_commit_id against main's. Identical means the load didn't land — empty fork left behind.
For pointed verification of a single record:
omnigraph export --config X --type <NodeType> | grep <slug>
omnigraph export --config X --type <EdgeType> | grep <slug>504 Gateway Timeout: response lost, write status unknown
A 504 from the proxy means the server didn't respond within the idle timeout. Two server-side outcomes are possible — the 504 alone cannot distinguish them:
1. Write completed and published — landed, main's head advanced. Common for small mutations finishing just past the 30s edge. 2. Write still in progress — will publish or fail soon. Re-check after a minute.
Always verify via commit list before retrying. Blind retry on append-only types creates duplicates.
Fork-branch 504 fingerprint
load --from <base> creates the branch before loading data. A timed-out fork-load where the data didn't land leaves an empty branch at <base>'s head. Stale numbered branches (feature-v2, -v3, -v4 …) all sitting at the same graph_commit_id as main are the fingerprint of prior 504-blocked attempts.
Find them by comparing each branch's head against main's in omnigraph branch list --config X --json, then delete the empty ones.
Targeting a remote graph: --server and login
load, query, and mutate all run against a remote omnigraph-server endpoint — there is no local-only restriction as of 0.7.0. Address an operator-defined server by name instead of pasting URLs and juggling tokens:
echo "$TOKEN" | omnigraph login intel-dev # stores it in ~/.omnigraph/credentials (0600)
omnigraph load --server intel-dev --graph spike \
--data delta.jsonl --from main --mode merge --branch staging--server <name> resolves the URL from ~/.omnigraph/config.yaml and the token via OMNIGRAPH_TOKEN_<NAME> or the credentials file. A token is only ever sent to the server it is keyed to. --graph <id> selects the graph on a multi-graph server.
Version drift / sync_branch()
version drift on node:<Type>: snapshot pinned vN but dataset is at vM — call sync_branch() and retrysync_branch()is not a CLI command — it's a server-internal directive that leaked into the error text. Don't go looking for it.- Cause: another actor committed to
mainbetween your CLI's snapshot pin and yourmutateattempt. - Usually self-resolves on retry — the next call re-pins.
- Calling
omnigraph snapshotdoes not reliably re-pin for subsequentmutates in the same session. - If persistent, fall back to
load --from mainonto a fresh branch — a forked branch doesn't suffer from concurrent-commit drift onmain. - The cleaner, modern form of this conflict is a structured
manifest_conflict409 — see below.
manifest_conflict 409 — stale snapshot, retry
When another actor commits to the same branch between your query's snapshot pin and your write, the server returns a structured `manifest_conflict` 409 carrying table_key / expected / actual, rather than silently overwriting. Since v0.4.2 this is the form most concurrent update/delete/merge races take.
- Retry it. A 409 means your write was computed against a stale view and was rejected before committing — there is no partial state and no duplicate risk. Re-issue the same call; it re-pins to the new head.
- Concurrent
mutate× branch-merge on the same target branch resolves to either success or a clean 409 depending on who wins the server's per-table queue — both outcomes are safe.
429 Too Many Requests — back off, then retry
The server applies per-actor admission control to every mutating endpoint (mutate / load / schema apply / branch create·delete·merge). An actor that exceeds its in-flight-request or estimated-byte budget gets a structured HTTP 429 (code: too_many_requests) with a Retry-After header — instead of blocking unrelated actors behind a global lock.
- This is not a failed write — the write never started. Honor
Retry-Afterand retry; it is always safe (no partial write, no duplicate risk). - It's per-actor, so one noisy automation can't starve others. If you hit it constantly, batch less aggressively or space your calls out.
- Read-only endpoints are not admission-gated.
Duplicate risk on blind retry
After a 504, never retry without verifying first. Different node kinds have different retry semantics:
| Kind | Retry safety |
|---|---|
Pointer nodes (Org, Person, Opportunity, Channel, Actor, ActionItem, Artifact, Meeting, Technology, Campaign, UseCase) | ✓ Idempotent — @key upserts dedupe |
Append-only nodes (Signal, Claim, Decision, Event, Interaction, MarketingElement, Policy, Outcome) | ✗ Duplicates on retry — verify before retrying |
| Edges | ⚠ No @key. Verify via export --type <EdgeName> + grep. Some simple edges dedupe server-side; don't rely on it. |
Reading large schemas safely
Remote schemas can be large (tens of KB). Tools that cap stdout (~50KB is common) will truncate or duplicate the output silently — leading to memory-based answers from agents that look correct but reference nonexistent fields.
Always redirect to a file before reading:
omnigraph schema show --config X > /tmp/schema.pg
wc -l /tmp/schema.pgThen read the file with offset/limit, not via piped stdout.
Prevention checklist
- Keep mutations small. Single-node inserts finish well under the timeout.
- Prefer
mutateoverloadfor ≤ a handful of records. - Always run
commit listafter a 504 before deciding to retry. - For destructive or large-batch work, use
load --from mainonto a feature branch and verify the branch head before merging. - Read large schemas via file redirect, not piped stdout.
- A
429(throttle) or amanifest_conflict409(stale snapshot) is always safe to retry — the write never committed. HonorRetry-Afteron a 429.
Schema Authoring & Evolution
Contents
- Authoring (.pg files)
- Evolution (schema plan/apply)
- Supported types
- Decorators (quick reference)
- Interfaces
- Design principles
- Schema evolution in cluster mode
How to write and evolve .pg schemas in Omnigraph.
Authoring (.pg files)
Use // for comments
Not #. The compiler rejects # with a parse error that looks like:
parse error: expected schema_fileEnums are inline, not standalone
The compiler does not accept top-level enum Foo { ... } blocks. Put the values inline on the property:
kind: enum(product, technology, framework, concept, ops) @indexIf the same enum appears on multiple nodes, duplicate it inline — there's no shared enum type.
Lists contain scalars only
[String] and [I32] are fine. [Category] (a list of enum values) is not supported. Use [String] with query-side filtering, or use a single-valued enum property if one value is enough.
@embed takes a quoted string
embedding: Vector(3072) @embed("text") @indexNot @embed(text). The source property name is a string literal.
Edge constraints go inside a body block
@unique(src, dst) on an edge goes inside { }, after @card(...):
edge PartOfArtifact: Chunk -> InformationArtifact @card(1..1) {
@unique(src)
}Lint after every edit
omnigraph lint --schema schema.pg --query queries/signals.gqThis validates the schema and the queries against it. No running repo required. Wire it into a precommit hook.
Evolution (schema plan/apply)
Plan before apply — always
omnigraph schema plan --schema next.pg s3://bucket/repo --json
# inspect "supported": true|false and the step list
omnigraph schema apply --schema next.pg s3://bucket/repoIf supported: false, fix the source before applying. Plan is free; run it as often as needed.
Plan/apply diagnostics carry stable codes of the form `OG-XXX-NNN` (since v0.5.0) — match on the code, not the free-form message text.
Destructive drops are gated (since v0.5.0). Dropping a property or type is a soft drop by default (or rejected); to actually lose data you must opt in:
omnigraph schema apply --schema next.pg s3://bucket/repo --allow-data-lossOver HTTP the equivalent is {"allow_data_loss": true} in the schema-apply body. Without the flag, a destructive drop returns a structured diagnostic instead of silently deleting columns.
Apply is main-only
omnigraph schema apply rejects any non-main branches. Delete or merge feature branches first. This is deliberate: schema changes don't go through review branches. They go straight to main via plan + apply.
Rename, don't replace
Use @rename_from(...) on renames so the planner emits a rename step (preserves data), not a drop+add pair (loses data):
node Account @rename_from("User") {
full_name: String @rename_from("name")
}Works on node types, edge types, and properties.
Required properties need a backfill plan
Adding a non-nullable property to an existing node is rejected as unsupported. Pattern:
1. Add as optional: new_prop: String? 2. Apply 3. Backfill via a mutate or load --mode merge 4. Tighten to required in a follow-up apply: new_prop: String
Keep @key stable
Changing the key field is effectively a replace — it invalidates every external reference to the node. Treat identity changes as deliberate, multi-step migrations, not casual field renames.
schema apply blocks writes while running
No concurrent mutations during an apply. Plan for a short read-only window.
Supported Types
- Scalars:
String,Bool,I32,I64,U32,U64,F32,F64,Date,DateTime,Blob - Collections:
Vector(N)(fixed-size float vector),[ScalarType](list of scalar) - Enums:
enum(value1, value2, ...)— inline only, values can contain alphanumerics, underscores, hyphens - Optional: any type +
?suffix (String?,[I32]?,Vector(4)?)
Decorators (quick reference)
Property-level:
@key— primary key (implies index; usually one per node)@unique— uniqueness constraint@index— query optimization@range(min, max)— numeric bounds (open ranges allowed)@check(prop, "regex")— regex pattern validation on a String property@embed("source_prop")— embed from a String source into a Vector property@description("...")— metadata (no migration impact)@instruction("...")— semantic hint for LLMs/operators
Edge-level:
@card(min..max)— edge cardinality (default:0..*)
Type-level (nodes/edges/properties):
@rename_from("OldName")— migration-aware rename
Group-level (inside body block):
@unique(prop1, prop2)— composite uniqueness, enforced as a true tuple key at both intake and merge (works on edges too:@unique(src, dst)). Columns must reduce to a scalar key:@uniqueon a[List]/Blobcolumn is rejected loudly atload(it used to be silently un-enforced — fixed in #160).@index(prop1, prop2)— composite index
Interfaces
Supported but rarely used. Declare shared property contracts and node types implement them:
interface Searchable {
title: String @index
embedding: Vector(3072) @embed("title")
}
node Doc implements Searchable {
slug: String @key
body: String
}Most schemas are fine without interfaces. Reach for them only when 3+ node types need to share a property contract.
Design Principles (brief)
- Identity is explicit — use
@keyon a semantic slug, not internal row IDs - Narrow types —
DateoverStringfor dates,enumoverStringfor lifecycle states - Edge semantics matter — prefer
AuthoredByoverRelatedTo - Constraints live in the schema —
@unique,@range,@cardkeep invariants out of application code - Schemas are reviewable — clear names, explicit enums, obvious keys
Schema Evolution in Cluster Mode
In a cluster deployment there is no direct `omnigraph schema apply` — the schema is declared (graphs.<id>.schema: in cluster.yaml) and converged:
$EDITOR schema.pg
omnigraph cluster plan --config . # shows the engine's migration steps
omnigraph cluster apply --config . --as <you>
# restart the --cluster server to serve the new shapeDifferences from direct schema apply (on a non-cluster store): soft drops only (--allow-data-loss is not reachable from cluster apply — prior versions retain dropped columns), and out-of-band schema changes on the live graph are drift — cluster refresh flags them and the next apply converges the graph back to the declared schema. Everything else in this file (@rename_from, backfills, linting, enum discipline) applies unchanged to the .pg you edit.
Search & Embeddings
Contents
- Embeddings are schema-declared
- Generating embeddings
- Embeddings +
load --mode mergeinteraction - Search functions in queries
- The key pattern: scope first, rank second
- Model / config
Vector embeddings and text search in Omnigraph.
Embeddings are Schema-Declared
node Chunk {
text: String
chunk_index: I32
embedding: Vector(3072) @embed("text") @index
createdAt: DateTime
}Vector(N)— fixed-size float vector@embed("source_prop")— what text field to embed from (quoted string)@index— enables vector search on this field
The schema says where embeddings live and what they come from. Queries don't recompute; they read.
Generating Embeddings
First time / refresh missing
omnigraph embed --seed embed-config.yamlDefault mode is fill_missing — only generates embeddings for rows without one.
Re-embed everything
omnigraph embed --seed embed-config.yaml --reembed_allUse when:
- You changed the source field:
@embed("body")→@embed("title") - You mutated text at scale and need fresh embeddings
- You switched embedding models (rare)
Selective refresh
omnigraph embed --seed embed-config.yaml --select "Chunk:chunk_index=42"Regenerate only rows matching the selector.
Clean (delete) embeddings
omnigraph embed --seed embed-config.yaml --cleanEmbeddings + load --mode merge Interaction
`load --mode merge` does NOT recompute embeddings.
If you update rows whose source fields feed into @embed(...), the source updates but the embedding stays stale.
Two fixes: 1. Run omnigraph embed --reembed_all after the merge 2. Use load --mode overwrite instead, which re-triggers embedding on load
Search Functions in Queries
All ranking functions require limit N — they're order operators, not filters.
Vector similarity
query nearest_chunks($q: Vector(3072)) {
match { $c: Chunk }
return { $c.text }
order { nearest($c.embedding, $q) }
limit 10
}BM25 text ranking
query top_titles($q: String) {
match { $d: Doc }
return { $d.slug, $d.title }
order { bm25($d.title, $q) }
limit 10
}Hybrid (Reciprocal Rank Fusion)
query hybrid($vq: Vector(3072), $tq: String) {
match { $d: Doc }
return { $d.slug, $d.title }
order { rrf(nearest($d.embedding, $vq), bm25($d.title, $tq)) }
limit 10
}Text filter (not ranking — no limit required)
match {
$d: Doc
search($d.title, $q) // full-text filter
fuzzy($d.title, $q, 2) // fuzzy filter, max 2 edits
match_text($d.body, $q) // phrase filter
}The Key Pattern: Scope First, Rank Second
Filter with graph traversal before invoking vector or text ranking. Ranking over a narrow set is both cheaper and more relevant.
query related_chunks($artifact_slug: String, $q: Vector(3072)) {
match {
$a: InformationArtifact { slug: $artifact_slug }
$c partOfArtifact $a // scope: only this artifact's chunks
}
return { $c.text }
order { nearest($c.embedding, $q) } // rank: vector similarity within scope
limit 10
}Don't rank over the entire chunk set if you know a traversal can narrow it first.
Model / Config
Omnigraph uses two distinct embedding clients — don't conflate them:
| Client | When it runs | Default model | Configured via |
|---|---|---|---|
| Engine / load-time | At load, when an @embed("source") field is populated (and omnigraph embed) | gemini-embedding-2-preview (3072-dim) | GEMINI_API_KEY, OMNIGRAPH_GEMINI_BASE_URL, OMNIGRAPH_EMBED_*, OMNIGRAPH_EMBEDDINGS_MOCK |
| Compiler / query-time | When a query passes a string to a ranking op (e.g. nearest($c.embedding, "some text")) and the server auto-embeds it | text-embedding-3-small (OpenAI-style) | NANOGRAPH_EMBED_MODEL, OPENAI_API_KEY, OPENAI_BASE_URL, NANOGRAPH_EMBEDDINGS_MOCK |
The vector stored in the schema is produced by the load-time (engine) client, so Vector(N) must match that model's output dimension — Vector(3072) for gemini-embedding-2-preview. If you point the query-time client at a model with a different dimension than your stored vectors, similarity search returns garbage or errors — keep both sides on the same dimension. Vectors are stored L2-normalized.
HTTP Server & Cedar Policy
Contents
- Starting the server (boot sources)
- HTTP routes
- Auth
- Setup operations bypass the server
- Cedar policy
- Multi-graph mode
- Server + policy together
- Cluster-booted servers
How to run omnigraph-server and gate operations with Cedar policies.
Starting the Server
The server is the canonical runtime entry point — all CLI queries, mutations, and admin ops go through it. Boot is cluster-only (RFC-011): the server boots from a cluster and serves N graphs (N ≥ 1) under nested routes. There is no single-graph / bare-URI / omnigraph.yaml boot.
omnigraph-server --cluster ./company-brain --bind 127.0.0.1:8080 # a config directory …
omnigraph-server --cluster s3://bucket/prefix --bind 0.0.0.0:8080 # … or a storage-root URI (config-free)--cluster boots from the cluster's applied revision (see Cluster-Booted Servers below). Run it in a separate terminal or background process.
HTTP Routes
All per-graph routes are nested under /graphs/{id}/... ({id} = a graph id from the applied cluster); bare flat paths (/query, /snapshot, …) return 404. /healthz and /graphs stay flat.
| Route | Purpose |
|---|---|
GET /healthz | liveness probe (flat) |
GET /graphs | enumerate served graphs (flat; graph_list-gated) |
GET /graphs/{id}/snapshot?branch= | table state + row counts |
POST /graphs/{id}/query | read query (canonical; /read = deprecated alias) |
POST /graphs/{id}/mutate | mutation (/change = deprecated alias) |
POST /graphs/{id}/load | bulk JSONL load, 32 MB; branch creation opt-in via from (/ingest = deprecated alias) |
POST /graphs/{id}/export | NDJSON stream of a branch |
GET /graphs/{id}/queries · POST /graphs/{id}/queries/{name} | stored-query catalog (read) + invocation (invoke_query, +change for a stored mutation; deny == 404) |
GET /graphs/{id}/schema · POST /graphs/{id}/schema/apply | read .pg · migrate (schema_apply) |
GET/POST /graphs/{id}/branches · DELETE …/branches/{b} · POST …/branches/merge | branch ops |
GET /graphs/{id}/commits?branch= · …/commits/{commit_id} | history |
Read routes take ?branch=main or ?snapshot=<id>. Writes publish directly and commit atomically via __manifest; use the commits route for write/audit history.
Auth
Set bearer tokens on the server process. Three sources, in precedence: OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET (AWS Secrets Manager) → OMNIGRAPH_SERVER_BEARER_TOKENS_JSON/_FILE (JSON {actor_id: token}) → OMNIGRAPH_SERVER_BEARER_TOKEN (single token, actor default):
OMNIGRAPH_SERVER_BEARER_TOKENS_JSON='{"act-reader":"s3cret"}' \
omnigraph-server --cluster ./company-brain --bind 0.0.0.0:8080On the client side (0.7.0), register the server once and store its token out of band:
echo "s3cret" | omnigraph login remote # → ~/.omnigraph/credentials (0600)
omnigraph query get_signal --server remote --graph spike --params '{"slug":"sig-foo"}'--server remote resolves the URL from ~/.omnigraph/config.yaml's servers: and the token via OMNIGRAPH_TOKEN_REMOTE or the credentials file. A token is only ever sent to the server it is keyed to.
Running without auth requires an explicit opt-in
You can no longer just "leave auth off." Since v0.6.0 the server refuses to start when it has neither bearer tokens nor a policy file, unless you explicitly opt in:
omnigraph-server --cluster . --unauthenticated
# or: OMNIGRAPH_UNAUTHENTICATED=1 omnigraph-server --cluster .This is a guardrail against accidentally shipping an open server. For pure local dev, pass --unauthenticated deliberately.
Setup Operations Bypass the Server
init and local load write storage directly — they don't go through the server (a remote load is server-orchestrated, POSTing /load). Pass the repo URI:
omnigraph init --schema schema.pg s3://my-bucket/repos/<name>
omnigraph load --data seed.jsonl --mode overwrite s3://my-bucket/repos/<name>Everything else — query, mutate, snapshot, schema plan/apply, branch, commit — goes through the running server.
Cedar Policy
Omnigraph can gate sensitive actions with Cedar policies.
Default-deny posture
Policy is enforced engine-wide (every authoring path calls the same gate), and the default is closed, not open:
| Server state | Bearer tokens | Policy file | Behavior |
|---|---|---|---|
| Open | no | no | Every request permitted — but the server refuses to start without --unauthenticated / OMNIGRAPH_UNAUTHENTICATED=1. |
| DefaultDeny | yes | no | Every authenticated request for an action other than read is rejected (HTTP 403). "Tokens but forgot the policy file" no longer ships the illusion of protection. |
| PolicyEnabled | yes | yes | Requests are evaluated against your Cedar rules. |
So configuring a policy file is what enables writes — there is no "permit everything by default" mode once tokens are set.
Gated actions
Per-graph actions (evaluated against the graph being addressed):
| Action | Protects |
|---|---|
read | query execution |
export | data export |
change | mutations |
invoke_query | stored-query invocation via POST /graphs/{id}/queries/{name} (graph-scoped, not branch-scoped). A stored mutation is double-gated — it also passes change. For a caller without the grant, a denial and an unknown query name both return the same 404 so the catalog can't be probed. |
schema_apply | schema migrations |
branch_create | branch creation |
branch_delete | branch deletion |
branch_merge | merges (especially into protected branches) |
admin exists but is reserved (no call site yet — don't write rules for it). A server-scoped graph_list action gates GET /graphs; declare it in a [cluster]-scoped bundle.
For any shared repo, gate at least schema_apply and branch_merge.
Where policy is declared
Cedar bundles are declared in cluster.yaml and attach via applies_to: [cluster] is the server-level engine (gates graph_list / GET /graphs); [<graph-id>] is that graph's engine (gates invoke_query, read, change, branch_*, schema_apply). cluster apply publishes them and the --cluster server enforces the applied revision. The policy.yaml rule format (below) is the bundle content.
policy.yaml shape
The policy model is allow-only: every rule is a permit. You grant capabilities to groups; anything ungranted is denied by default. There is no `deny` / `effect` key — to forbid something, simply don't grant it.
version: 1 # required; must be 1
groups:
admins: [act-alice, act-bob]
team: [act-carol, act-dan]
protected_branches:
- main
rules:
- id: admins-can-apply-schema # rules use `id`, not `name`
allow: # required `allow:` block
actors: { group: admins } # references a group by name
actions: [schema_apply]
target_branch_scope: protected
- id: team-can-merge-to-protected
allow:
actors: { group: team }
actions: [branch_merge]
target_branch_scope: protected
- id: team-can-read-write-unprotected
allow:
actors: { group: team }
actions: [read, change]
branch_scope: unprotectedTo "block unreviewed schema applies," you don't write a deny rule — you just don't grant schema_apply to that group. Default-deny does the rest.
Scope rules (a rule's allow block may use at most one):
branch_scope: any | protected | unprotected— forread,export,change(matches the source branch).target_branch_scope: any | protected | unprotected— forschema_apply,branch_create,branch_delete,branch_merge(matches the destination branch).
Validate, test, explain
# Compile Cedar + check the cluster's applied policies
omnigraph policy validate --cluster .
# Run declarative test cases
omnigraph policy test --cluster . --tests policy.tests.yaml
# Debug a single decision
omnigraph policy explain \
--actor act-alice \
--action schema_apply \
--target-branch main \
--cluster .Test cases (policy.tests.yaml)
version: 1 # required; must be 1
cases:
- id: alice-can-apply-schema # cases use `id`, not `name`
actor: act-alice
action: schema_apply
target_branch: main # schema_apply is target-branch scoped
expect: allow # `allow` / `deny` (not `permit`)
- id: random-user-cannot-merge-to-main
actor: act-random
action: branch_merge
target_branch: main
expect: denyRun policy test after every policy edit. Tests are cheap.
Multi-graph serving
A --cluster server serves every graph in the applied cluster, each under /graphs/{id}/.... GET /graphs enumerates them (sorted by id), gated by the cluster-level graph_list action — even under --unauthenticated, topology stays closed until a [cluster] policy grants it. omnigraph graphs list mirrors it (remote servers only).
Policy attaches at two levels via cluster.yaml applies_to:
[<graph-id>]— per-graph rules (read,change,branch_*,schema_apply,invoke_query).[cluster]— server-level rules (graph_list).
There is no runtime add/remove of graphs — edit cluster.yaml, cluster apply, restart.
Server + Policy Together
When the server is running with a policy file: 1. Every request resolves the actor from the bearer token (the client cannot set actor identity) and checks it against Cedar rules. 2. Unauthorized requests return 403 Forbidden. 3. The CLI doesn't bypass policy when it connects over HTTP — it's enforced at the server. Enforcement is also engine-wide, so CLI direct-engine writes and embedded SDK consumers hit the same gate.
Setup ops (init, load) write storage directly. With a policy configured they still flow through the engine-layer enforce gate for the actor you pass via --as (or operator.actor in ~/.omnigraph/config.yaml); gate the raw storage layer too (S3 bucket ACLs, object locks) if the bucket is shared.
Cluster-Booted Servers
omnigraph-server --cluster <dir|s3://> is the only boot source (covered above). It serves the cluster's applied revision: cluster apply changes take effect on the next restart (no hot reload), and boot is fail-fast with named remedies for missing/pending/tampered state. Bearer tokens and bind stay process-level (env/flags). See references/cluster.md.
Stored-Query Registries
A stored query is a .gq query that the server loads, type-checks at startup, and exposes by name — without ever accepting ad-hoc query source from the client. It's how you publish a vetted, typed query surface to remote callers and MCP tools.
This is a server-side feature introduced in v0.6.1. It is distinct from CLI aliases: (see `aliases.md`): an alias is local client ergonomics; a stored query is a server-published, policy-gated endpoint.
Declaring stored queries (cluster.yaml)
Stored queries are declared in the cluster's cluster.yaml — every query <name> in the listed .gq files registers:
graphs:
<id>:
schema: schema.pg
queries: queries/ # discover every `query <name>` in queries/*.gqqueries also accepts an explicit file list ([a.gq, b.gq]) or a fine-grained name: { file: … } map; an unparseable .gq or a duplicate query name across files fails cluster validate. cluster apply publishes them to the content-addressed catalog, and the --cluster server type-checks and serves every applied query. Every applied query is listed (per-query mcp:/expose flags are a planned phase).
CLI
omnigraph queries validate # type-check every stored query against the live schema (offline; opens the graph; exits non-zero on drift)
omnigraph queries list # print the addressed graph's registry: query names and typed paramsvalidatecatches schema drift without restarting the server — run it after aschema applyor before deploying a config change. The server also runs this check at startup and refuses to boot on drift or on a duplicate MCP tool name.validateopens the graph (address with--store <uri>or a positional URI);listreads the addressed graph's catalog.queriesis distinct fromlint—lintvalidates a single.gqfile you point it at;queries validatevalidates the registry the server will actually serve.
HTTP surface
| Route | Gate | Purpose |
|---|---|---|
GET /graphs/{id}/queries | read | Typed tool catalog of the served queries. Graph-wide (branch-independent; read authorized against main). |
POST /graphs/{id}/queries/{name} | invoke_query (+ change for a stored mutation) | Invoke a named query. Body carries params only — never .gq source. A stored mutation cannot target a snapshot (400); a param type error is a structured 400 naming the param. |
?branch= / ?snapshot= query params apply to POST /graphs/{id}/queries/{name} reads; branch/snapshot access stays enforced by the inner read/change gate (invoke_query itself is graph-scoped, not branch-scoped).
Policy gating (invoke_query)
- `invoke_query` is a per-graph Cedar action gating the whole stored-query invocation surface. Grant it like any other action (see `server-policy.md`).
- Stored mutations are double-gated: the caller needs
invoke_queryto reach the query andchangefor the write. An actor withinvoke_querybut notchangegets403on a stored mutation. - Deny == unknown: for a caller lacking
invoke_query, a denial and an unknown query name return the same 404 (identical body) — the catalog can't be probed. A caller who holdsinvoke_querymay still get a403from the inner gate for a query it can'tread/change, so existence is visible to grant-holders by design. - Default-deny mode (bearer tokens, no
policy.file) permits onlyread, so every/graphs/{id}/queries/{name}call returns404until aninvoke_queryrule is configured.
MCP exposure
Every applied query is listed in GET /graphs/{id}/queries as a typed MCP tool. Per-query exposure controls (mcp.expose, tool_name) are a planned phase — there is no per-query mcp: flag in cluster mode today.
Note on per-query authorization
The catalog is not Cedar-filtered per query yet: a caller with read but not invoke_query can list a query it cannot invoke (invocation would 404). Per-query authorization is future work; for now the catalog is a discovery surface and invoke_query is the invocation gate.