diff --git a/AGENTS.md b/AGENTS.md
index 0ef8f92..378de88 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -100,7 +100,7 @@ Full diagram and concurrency model: [docs/dev/architecture.md](docs/dev/architec
| Audit / actor tracking | [docs/user/operations/audit.md](docs/user/operations/audit.md) |
| Error taxonomy and result serialization | [docs/user/operations/errors.md](docs/user/operations/errors.md) |
| Install (binary / Homebrew / source / channels) | [docs/user/install.md](docs/user/install.md) |
-| Deployment (binary / container / RustFS bootstrap / auth / build variants) | [docs/user/deployment.md](docs/user/deployment.md) |
+| Deployment (binary / container / S3-local testing / auth / build variants) | [docs/user/deployment.md](docs/user/deployment.md) |
| CI / release workflows | [docs/dev/ci.md](docs/dev/ci.md) |
| Code ownership (CODEOWNERS source of truth, roles, regeneration) | [docs/dev/codeowners.md](docs/dev/codeowners.md) |
| Branch protection policy (declarative, applied via `scripts/apply-branch-protection.sh`) | [docs/dev/branch-protection.md](docs/dev/branch-protection.md) |
@@ -192,7 +192,7 @@ cargo test -p omnigraph-engine --features failpoints --test failpoints # fault
cargo build -p omnigraph-server --features aws # AWS Secrets Manager bearer-token source
```
-S3-backed tests (`s3_storage`, and the S3 paths in server/CLI system tests) **skip** unless `OMNIGRAPH_S3_TEST_BUCKET` + `AWS_*` (incl. `AWS_ENDPOINT_URL_S3` for non-AWS) are set; CI runs them against containerized RustFS. `scripts/local-rustfs-bootstrap.sh` stands up a local S3 environment.
+S3-backed tests (`s3_storage`, and the S3 paths in server/CLI system tests) **skip** unless `OMNIGRAPH_S3_TEST_BUCKET` + `AWS_*` (incl. `AWS_ENDPOINT_URL_S3` for non-AWS) are set; CI runs them against containerized RustFS. To run RustFS/MinIO yourself, see [docs/user/deployment.md](docs/user/deployment.md) → *Testing against S3 locally*.
CI does **not** run `clippy` or `rustfmt` as gates — but `cargo test --workspace --locked` is the exact gate, so run it before pushing. Two non-test CI checks: `scripts/check-agents-md.sh` (doc cross-link integrity — run it after moving/renaming docs) and OpenAPI drift (`crates/omnigraph-server/tests/openapi.rs` regenerates `openapi.json`; set `OMNIGRAPH_UPDATE_OPENAPI=1` to update the checked-in copy when a server/API change is intentional).
@@ -268,7 +268,8 @@ omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act
| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **cluster-only boot (RFC-011): always `--cluster
`, serving N graphs (N ≥ 1) under multi-graph routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Add/remove graphs via `cluster apply` and restart.** |
| CLI with config | — | two-surface config (team `cluster.yaml` dir + per-operator `~/.omnigraph/config.yaml`), scope addressing (`--store`/`--server`/`--cluster`/`--profile`/defaults, RFC-011), aliases, multi-format output (json/jsonl/csv/kv/table) |
| Audit / actor tracking | — | `_as` write APIs + actor map in commit graph |
-| Local RustFS bootstrap | — | `scripts/local-rustfs-bootstrap.sh` one-shot S3-backed dev environment |
+| Local S3 testing | — | run RustFS/MinIO + the `AWS_*` env; see [docs/user/deployment.md](docs/user/deployment.md) → *Testing against S3 locally* |
+| Agent skill | — | `skills/omnigraph` — operational playbook for driving Omnigraph; install with `npx skills add ModernRelay/omnigraph@omnigraph` |
---
diff --git a/README.md b/README.md
index bee0fa5..e1a99f6 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ Hundreds of agents can enrich the graph on parallel isolated branches and change
- Git-style versioning & branching
- Multimodal retrieval (graph+vector/fts+filters) optimized for context assembly
-- Object storage native (S3, RustFS)
+- Runs on the local filesystem or any S3-compatible object store (AWS S3, R2, MinIO, RustFS)
- Native blob-as-data support (docs, images, videos, etc)
- VPC, On-prem, hybrid deployment
- [`Lance`](https://github.com/lance-format/lance) format as open storage layer
@@ -52,29 +52,45 @@ brew tap ModernRelay/tap
brew install ModernRelay/tap/omnigraph
```
-For starter graphs and agent skills to bootstrap and operate Omnigraph, see [`ModernRelay/omnigraph-cookbooks`](https://github.com/ModernRelay/omnigraph-cookbooks).
+## Set it up with an AI agent
-## One-Command Local RustFS Bootstrap
+Omnigraph is built to be set up by coding agents. Paste this into Claude Code,
+Cursor, or any agent that can read a URL, install a package, and run a shell
+command — it installs the skill, reads the docs, and walks you through setup for
+your use case:
-```bash
-curl -fsSL https://raw.githubusercontent.com/ModernRelay/omnigraph/main/scripts/local-rustfs-bootstrap.sh | bash
+```text
+Help me set up Omnigraph (a lakehouse-native graph engine for agents).
+
+1. Install the Omnigraph skill so you operate it correctly:
+ npx skills add ModernRelay/omnigraph@omnigraph
+2. Read the docs at https://github.com/ModernRelay/omnigraph — start with
+ docs/user/quickstart.md, then docs/user/clusters/index.md.
+3. Skim the starter graphs and seed data in the cookbooks:
+ https://github.com/ModernRelay/omnigraph-cookbooks
+4. Ask me what I want to build (company brain, agent memory, dev graph,
+ research / R&D layer, …). Then install the CLI, stand up a first graph for
+ that use case, load a little data, and run a query so I can see it working.
```
-That bootstrap:
+Works with any agent that can browse a URL, install a package, and run a shell.
-- starts RustFS on `127.0.0.1:9000`
-- creates a bucket and S3-backed graph
-- loads the checked-in context fixture
-- launches `omnigraph-server` on `127.0.0.1:8080`
+## Agent skill & starter graphs
-Docker must be installed and running first.
+This repo ships the [**`omnigraph` agent skill**](skills/omnigraph) — the
+operational playbook (cluster mode, the two config surfaces, schema evolution,
+query linting, data writes, branches, Cedar policy, and common gotchas) that
+teaches a coding agent to drive Omnigraph correctly. Install it with:
-The RustFS bootstrap prefers the rolling `edge` binaries and only falls back to
-source builds when release assets are unavailable.
+```bash
+npx skills add ModernRelay/omnigraph@omnigraph
+```
-If a previous run left objects under the same graph prefix but did not finish
-initializing the graph, rerun with `RESET_REPO=1` or set `PREFIX` to a new
-value.
+For ready-to-run graphs with real seed data (company brain, VC operating system,
+pharma & industry intel),
+[`ModernRelay/omnigraph-cookbooks`](https://github.com/ModernRelay/omnigraph-cookbooks)
+is the fastest way to see Omnigraph shaped to a real domain. To rehearse the S3
+path locally, see [deployment.md → Testing against S3 locally](docs/user/deployment.md#testing-against-s3-locally).
## Common Commands
diff --git a/docs/user/deployment.md b/docs/user/deployment.md
index 21b8087..a0d8e9f 100644
--- a/docs/user/deployment.md
+++ b/docs/user/deployment.md
@@ -129,49 +129,46 @@ shape above) — the simplest AWS architecture.
unvalidated** — boot is lock-free read-only so it should compose, but it
is not yet exercised by tests.
-## One-Command Local RustFS Bootstrap
+## Testing against S3 locally
-The easiest local S3-backed deployment path is:
+To exercise the S3 storage path without a cloud account, run any S3-compatible
+store in Docker and point the standard `AWS_*` environment at it. RustFS is
+shown; MinIO works the same way.
```bash
-curl -fsSL https://raw.githubusercontent.com/ModernRelay/omnigraph/main/scripts/local-rustfs-bootstrap.sh | bash
+docker run -d --name omnigraph-s3 -p 9000:9000 \
+ -e RUSTFS_ACCESS_KEY=omnigraph -e RUSTFS_SECRET_KEY=omnigraph \
+ -e RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true \
+ rustfs/rustfs:latest /data
+
+export AWS_ACCESS_KEY_ID=omnigraph AWS_SECRET_ACCESS_KEY=omnigraph \
+ AWS_REGION=us-east-1 AWS_ENDPOINT_URL_S3=http://127.0.0.1:9000 \
+ AWS_ALLOW_HTTP=true AWS_S3_FORCE_PATH_STYLE=true
+
+# create the bucket once (any S3 client works)
+aws --endpoint-url "$AWS_ENDPOINT_URL_S3" s3 mb s3://omnigraph-local
```
-The bootstrap:
+Now an `s3://…` URI works anywhere a graph or cluster root is expected. Root a
+cluster on the bucket and serve it config-free:
-- starts a local RustFS-backed object store
-- creates a bucket and S3-backed Omnigraph graph
-- loads the checked-in context fixture
-- starts `omnigraph-server` on `127.0.0.1:8080`
+```bash
+# cluster.yaml
+# version: 1
+# storage: s3://omnigraph-local/clusters/demo
+# graphs: { demo: { schema: schema.pg } }
-Supported behavior:
+omnigraph cluster validate --config .
+omnigraph cluster import --config .
+omnigraph cluster apply --config . --as you
+omnigraph load --data seed.jsonl --mode merge \
+ s3://omnigraph-local/clusters/demo/graphs/demo.omni
+omnigraph-server --cluster s3://omnigraph-local/clusters/demo \
+ --bind 127.0.0.1:8080 --unauthenticated
+```
-- downloads the rolling `edge` binary when one exists for the current platform
-- otherwise clones `ModernRelay/omnigraph` and builds from source
-- reuses an existing RustFS container if it is already running
-
-Useful overrides:
-
-- `WORKDIR=/path/to/state`
-- `BUCKET=omnigraph-local`
-- `PREFIX=graphs/context`
-- `RESET_REPO=1` to delete an existing partially initialized graph prefix before recreating it
-- `BIND=127.0.0.1:8080`
-- `RUSTFS_CONTAINER_NAME=omnigraph-rustfs-demo`
-
-The bootstrap expects:
-
-- Docker
-- `curl`
-- either a matching release asset or a local Rust toolchain plus `git`
-
-If `aws` is not installed, the script attempts a user-local AWS CLI install via
-`python3 -m pip`. Docker Desktop or another Docker daemon must already be
-running.
-
-If a previous bootstrap left objects behind under the selected `PREFIX` but did
-not finish initializing the graph, rerun with `RESET_REPO=1` or choose a new
-`PREFIX`.
+The same `AWS_*` contract applies to a production object store — swap the
+endpoint and credentials. CI exercises this path against containerized RustFS.
## Container Deployment
diff --git a/docs/user/quickstart.md b/docs/user/quickstart.md
index b39ff1b..dd8c2e7 100644
--- a/docs/user/quickstart.md
+++ b/docs/user/quickstart.md
@@ -53,10 +53,13 @@ query find_people($title: String) {
Run it:
```bash
-omnigraph read --query queries.gq --name find_people \
- --params '{"title":"Engineer"}' --format table graph.omni
+omnigraph query find_people --query queries.gq \
+ --params '{"title":"Engineer"}' --format table --store graph.omni
```
+The query name is positional; `--query` points at the `.gq` source and
+`--store` addresses the graph's storage directly.
+
The [query language](queries/index.md) covers `match`/`return`/`order`, and
[search](search/index.md) covers vector and full-text search.
diff --git a/scripts/local-rustfs-bootstrap.sh b/scripts/local-rustfs-bootstrap.sh
deleted file mode 100755
index 2425c77..0000000
--- a/scripts/local-rustfs-bootstrap.sh
+++ /dev/null
@@ -1,425 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-REPO_SLUG="${REPO_SLUG:-ModernRelay/omnigraph}"
-SOURCE_REF="${SOURCE_REF:-main}"
-RELEASE_CHANNEL="${RELEASE_CHANNEL:-edge}"
-WORKDIR="${WORKDIR:-$PWD/.omnigraph-rustfs-demo}"
-RUSTFS_CONTAINER_NAME="${RUSTFS_CONTAINER_NAME:-omnigraph-rustfs-demo}"
-# Pinned to 1.0.0-beta.8 (2026-06-10), matching CI (.github/workflows/ci.yml).
-# beta.4+ has a credentials-policy check that refuses to start when the
-# access/secret keys are values it considers "default" (rustfsadmin/rustfsadmin
-# here); this script passes RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true
-# below, so overriding RUSTFS_IMAGE to another tag is safe.
-RUSTFS_IMAGE="${RUSTFS_IMAGE:-rustfs/rustfs:1.0.0-beta.8}"
-RUSTFS_DATA_DIR="${RUSTFS_DATA_DIR:-$WORKDIR/rustfs-data}"
-BUCKET="${BUCKET:-omnigraph-local}"
-PREFIX="${PREFIX:-repos/context}"
-BIND="${BIND:-127.0.0.1:8080}"
-AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-rustfsadmin}"
-AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-rustfsadmin}"
-AWS_REGION="${AWS_REGION:-us-east-1}"
-AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-http://127.0.0.1:9000}"
-AWS_ENDPOINT_URL_S3="${AWS_ENDPOINT_URL_S3:-$AWS_ENDPOINT_URL}"
-AWS_ALLOW_HTTP="${AWS_ALLOW_HTTP:-true}"
-AWS_S3_FORCE_PATH_STYLE="${AWS_S3_FORCE_PATH_STYLE:-true}"
-FORCE_BUILD="${FORCE_BUILD:-0}"
-RESET_REPO="${RESET_REPO:-0}"
-
-REPO_URI="s3://$BUCKET/$PREFIX"
-SERVER_LOG="$WORKDIR/omnigraph-server.log"
-SERVER_PID_FILE="$WORKDIR/omnigraph-server.pid"
-BIN_DIR=""
-FIXTURE_DIR=""
-AWS_BIN=""
-
-log() {
- printf '==> %s\n' "$*"
-}
-
-die() {
- printf 'error: %s\n' "$*" >&2
- exit 1
-}
-
-need_cmd() {
- command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
-}
-
-repo_root_from_shell() {
- if [ -f "$PWD/Cargo.toml" ] && [ -f "$PWD/crates/omnigraph/tests/fixtures/context.pg" ]; then
- printf '%s\n' "$PWD"
- return 0
- fi
-
- if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then
- local candidate
- candidate="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
- if [ -f "$candidate/Cargo.toml" ] && [ -f "$candidate/crates/omnigraph/tests/fixtures/context.pg" ]; then
- printf '%s\n' "$candidate"
- return 0
- fi
- fi
-
- return 1
-}
-
-latest_release_tag() {
- local json
- json="$(curl -fsSL "https://api.github.com/repos/$REPO_SLUG/releases/latest" 2>/dev/null || true)"
- printf '%s' "$json" | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
-}
-
-platform_asset_name() {
- local os arch
- os="$(uname -s)"
- arch="$(uname -m)"
-
- case "$os/$arch" in
- Linux/x86_64)
- printf 'omnigraph-linux-x86_64.tar.gz\n'
- ;;
- Darwin/arm64)
- printf 'omnigraph-macos-arm64.tar.gz\n'
- ;;
- *)
- return 1
- ;;
- esac
-}
-
-checksum_command() {
- if command -v shasum >/dev/null 2>&1; then
- printf 'shasum -a 256'
- return
- fi
-
- if command -v sha256sum >/dev/null 2>&1; then
- printf 'sha256sum'
- return
- fi
-
- die "missing checksum tool: expected shasum or sha256sum"
-}
-
-release_base_url() {
- case "$RELEASE_CHANNEL" in
- stable)
- printf 'https://github.com/%s/releases/latest/download\n' "$REPO_SLUG"
- ;;
- edge)
- printf 'https://github.com/%s/releases/download/edge\n' "$REPO_SLUG"
- ;;
- *)
- die "unsupported RELEASE_CHANNEL '$RELEASE_CHANNEL' (expected stable or edge)"
- ;;
- esac
-}
-
-verify_checksum() {
- local archive="$1"
- local checksum_file="$2"
- local expected actual tool
-
- expected="$(awk '{print $1}' "$checksum_file")"
- [ -n "$expected" ] || die "checksum file did not contain a SHA256 digest"
-
- tool="$(checksum_command)"
- actual="$($tool "$archive" | awk '{print $1}')"
-
- [ "$actual" = "$expected" ] || die "checksum verification failed for $(basename "$archive")"
-}
-
-ensure_aws_cli() {
- if command -v aws >/dev/null 2>&1; then
- AWS_BIN="$(command -v aws)"
- return
- fi
-
- need_cmd python3
-
- if ! python3 -m pip --version >/dev/null 2>&1; then
- python3 -m ensurepip --upgrade --user >/dev/null 2>&1 || die "aws cli not found and python3 pip bootstrap failed"
- fi
-
- log "Installing a user-local AWS CLI"
- python3 -m pip install --user awscli >/dev/null
- export PATH="$HOME/.local/bin:$PATH"
-
- command -v aws >/dev/null 2>&1 || die "aws cli installation succeeded but aws was not found on PATH"
- AWS_BIN="$(command -v aws)"
-}
-
-download_fixture_files() {
- local ref="$1"
- local fixture_target="$WORKDIR/fixtures"
- mkdir -p "$fixture_target"
-
- for file in context.pg context.jsonl; do
- curl -fsSL \
- "https://raw.githubusercontent.com/$REPO_SLUG/$ref/crates/omnigraph/tests/fixtures/$file" \
- -o "$fixture_target/$file" || return 1
- done
-
- FIXTURE_DIR="$fixture_target"
-}
-
-download_release_binaries() {
- local asset asset_stem archive_dir archive_path checksum_path base_url
-
- [ "$FORCE_BUILD" = "1" ] && return 1
-
- asset="$(platform_asset_name)" || return 1
- asset_stem="${asset%.tar.gz}"
- archive_dir="$WORKDIR/release"
- archive_path="$archive_dir/$asset"
- checksum_path="$archive_dir/$asset_stem.sha256"
- mkdir -p "$archive_dir" "$WORKDIR/bin"
- base_url="$(release_base_url)"
-
- log "Downloading release asset $asset"
- curl -fsSL \
- "$base_url/$asset" \
- -o "$archive_path" || return 1
- curl -fsSL \
- "$base_url/$asset_stem.sha256" \
- -o "$checksum_path" || return 1
- verify_checksum "$archive_path" "$checksum_path" || return 1
- tar -C "$WORKDIR/bin" -xzf "$archive_path" || return 1
-
- BIN_DIR="$WORKDIR/bin"
- if [ "$RELEASE_CHANNEL" = "stable" ]; then
- local tag
- tag="$(latest_release_tag)"
- [ -n "$tag" ] || return 1
- download_fixture_files "$tag" || return 1
- else
- download_fixture_files "main" || return 1
- fi
-}
-
-build_from_source() {
- local repo_root
- repo_root="${1:-}"
-
- if [ -z "$repo_root" ]; then
- need_cmd git
- need_cmd cargo
-
- repo_root="$WORKDIR/source"
- if [ ! -d "$repo_root/.git" ]; then
- log "Cloning $REPO_SLUG at $SOURCE_REF"
- git clone --depth 1 --branch "$SOURCE_REF" "https://github.com/$REPO_SLUG.git" "$repo_root"
- fi
- fi
-
- need_cmd cargo
- log "Building omnigraph binaries from source"
- (
- cd "$repo_root"
- cargo build --release --locked -p omnigraph-cli -p omnigraph-server
- )
-
- BIN_DIR="$repo_root/target/release"
- FIXTURE_DIR="$repo_root/crates/omnigraph/tests/fixtures"
-}
-
-setup_binaries() {
- local repo_root
- repo_root="$(repo_root_from_shell || true)"
-
- if [ -n "${OMNIGRAPH_BIN_DIR:-}" ]; then
- BIN_DIR="$OMNIGRAPH_BIN_DIR"
- if [ -n "${OMNIGRAPH_FIXTURE_DIR:-}" ]; then
- FIXTURE_DIR="$OMNIGRAPH_FIXTURE_DIR"
- elif [ -n "$repo_root" ]; then
- FIXTURE_DIR="$repo_root/crates/omnigraph/tests/fixtures"
- fi
- elif ! download_release_binaries; then
- if [ -n "$repo_root" ]; then
- build_from_source "$repo_root"
- else
- build_from_source
- fi
- fi
-
- [ -x "$BIN_DIR/omnigraph" ] || die "omnigraph binary not found in $BIN_DIR"
- [ -x "$BIN_DIR/omnigraph-server" ] || die "omnigraph-server binary not found in $BIN_DIR"
- [ -f "$FIXTURE_DIR/context.pg" ] || die "context fixture schema not found in $FIXTURE_DIR"
- [ -f "$FIXTURE_DIR/context.jsonl" ] || die "context fixture data not found in $FIXTURE_DIR"
-}
-
-start_rustfs() {
- mkdir -p "$RUSTFS_DATA_DIR"
-
- if docker ps --format '{{.Names}}' | grep -qx "$RUSTFS_CONTAINER_NAME"; then
- log "Reusing existing RustFS container $RUSTFS_CONTAINER_NAME"
- return
- fi
-
- if docker ps -a --format '{{.Names}}' | grep -qx "$RUSTFS_CONTAINER_NAME"; then
- log "Removing stopped RustFS container $RUSTFS_CONTAINER_NAME"
- docker rm -f "$RUSTFS_CONTAINER_NAME" >/dev/null
- fi
-
- log "Starting RustFS on $AWS_ENDPOINT_URL_S3"
- docker run -d \
- --name "$RUSTFS_CONTAINER_NAME" \
- -p 9000:9000 \
- -p 9001:9001 \
- -v "$RUSTFS_DATA_DIR:/data" \
- -e RUSTFS_ACCESS_KEY="$AWS_ACCESS_KEY_ID" \
- -e RUSTFS_SECRET_KEY="$AWS_SECRET_ACCESS_KEY" \
- -e RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true \
- "$RUSTFS_IMAGE" \
- /data >/dev/null
-}
-
-wait_for_rustfs() {
- local attempt
- for attempt in $(seq 1 30); do
- if "$AWS_BIN" --endpoint-url "$AWS_ENDPOINT_URL_S3" s3api list-buckets >/dev/null 2>&1; then
- return
- fi
- sleep 2
- done
-
- docker logs "$RUSTFS_CONTAINER_NAME" || true
- die "RustFS did not become ready"
-}
-
-ensure_bucket() {
- log "Ensuring bucket $BUCKET exists"
- "$AWS_BIN" --endpoint-url "$AWS_ENDPOINT_URL_S3" \
- s3api create-bucket --bucket "$BUCKET" >/dev/null 2>&1 || true
-}
-
-graph_prefix_has_objects() {
- local key_count
- key_count="$("$AWS_BIN" --endpoint-url "$AWS_ENDPOINT_URL_S3" \
- s3api list-objects-v2 \
- --bucket "$BUCKET" \
- --prefix "$PREFIX/" \
- --max-keys 1 \
- --query 'KeyCount' \
- --output text 2>/dev/null || true)"
-
- [ -n "$key_count" ] && [ "$key_count" != "None" ] && [ "$key_count" != "0" ]
-}
-
-reset_graph_prefix() {
- log "Removing existing objects under $REPO_URI"
- "$AWS_BIN" --endpoint-url "$AWS_ENDPOINT_URL_S3" \
- s3 rm "s3://$BUCKET/$PREFIX" --recursive >/dev/null
-}
-
-initialize_graph() {
- if "$BIN_DIR/omnigraph" snapshot "$REPO_URI" --json >/dev/null 2>&1; then
- log "Reusing existing graph at $REPO_URI"
- return
- fi
-
- if graph_prefix_has_objects; then
- if [ "$RESET_REPO" = "1" ]; then
- reset_graph_prefix
- else
- die "found existing objects under $REPO_URI but could not open an Omnigraph graph there. This usually means a previous bootstrap left a partially initialized prefix. Rerun with RESET_REPO=1 to delete that prefix and recreate it, or set PREFIX to a new value."
- fi
- fi
-
- log "Initializing graph at $REPO_URI"
- "$BIN_DIR/omnigraph" init --schema "$FIXTURE_DIR/context.pg" "$REPO_URI"
-
- log "Loading context fixture into $REPO_URI"
- "$BIN_DIR/omnigraph" load --data "$FIXTURE_DIR/context.jsonl" "$REPO_URI"
-}
-
-start_server() {
- mkdir -p "$WORKDIR"
-
- if [ -f "$SERVER_PID_FILE" ] && kill -0 "$(cat "$SERVER_PID_FILE")" >/dev/null 2>&1; then
- log "Stopping existing server process $(cat "$SERVER_PID_FILE")"
- kill "$(cat "$SERVER_PID_FILE")" >/dev/null 2>&1 || true
- sleep 1
- fi
-
- log "Starting omnigraph-server on $BIND"
- nohup "$BIN_DIR/omnigraph-server" "$REPO_URI" --bind "$BIND" >"$SERVER_LOG" 2>&1 &
- echo "$!" > "$SERVER_PID_FILE"
-}
-
-wait_for_server() {
- local bind_host bind_port health_host base_url
- bind_host="${BIND%:*}"
- bind_port="${BIND##*:}"
- health_host="$bind_host"
- if [ "$health_host" = "0.0.0.0" ]; then
- health_host="127.0.0.1"
- fi
- base_url="http://$health_host:$bind_port"
-
- for _ in $(seq 1 30); do
- if curl -fsSL "$base_url/healthz" >/dev/null 2>&1; then
- printf '%s\n' "$base_url"
- return
- fi
- sleep 1
- done
-
- cat "$SERVER_LOG" >&2 || true
- die "omnigraph-server did not pass /healthz"
-}
-
-print_summary() {
- local base_url="$1"
-
- cat </dev/null 2>&1 || die "docker is installed but the daemon is not reachable; start Docker Desktop or another daemon and rerun"
-
- export AWS_ACCESS_KEY_ID
- export AWS_SECRET_ACCESS_KEY
- export AWS_REGION
- export AWS_ENDPOINT_URL
- export AWS_ENDPOINT_URL_S3
- export AWS_ALLOW_HTTP
- export AWS_S3_FORCE_PATH_STYLE
-
- mkdir -p "$WORKDIR"
-
- setup_binaries
- ensure_aws_cli
- start_rustfs
- wait_for_rustfs
- ensure_bucket
- initialize_graph
- start_server
- print_summary "$(wait_for_server)"
-}
-
-main "$@"
diff --git a/skills/omnigraph/SKILL.md b/skills/omnigraph/SKILL.md
new file mode 100644
index 0000000..7bf044a
--- /dev/null
+++ b/skills/omnigraph/SKILL.md
@@ -0,0 +1,414 @@
+---
+name: omnigraph
+description: Store, retrieve, and query knowledge, memory, and relationships in an Omnigraph graph, and operate a local or remote Omnigraph deployment. Use when the user wants to capture or recall facts, notes, or entities, build or query a knowledge graph or agent memory, or run Omnigraph — and whenever you see Omnigraph CLI commands (omnigraph init/query/mutate/load/schema/lint/embed/branch/commit/login/profile/cluster), .pg schema or .gq query files, s3:// graph URIs, bearer-authed graph endpoints, 504 errors, or a cluster.yaml / omnigraph.yaml / ~/.omnigraph/config.yaml. Covers cluster-mode deployments (cluster.yaml plan/apply, omnigraph-server --cluster), the two config surfaces (cluster.yaml + ~/.omnigraph/config.yaml), schema evolution, query linting, data writes (mutate; load needs --mode/--from), branches, embeddings, Cedar policy, and remote ops. Especially important before schema apply (plan first), any load (--mode required), any .gq/.pg edit (lint after), or any remote write (verify via commit list).
+license: MIT (see LICENSE at repo root)
+compatibility: Requires omnigraph CLI >= 0.7.0 — the unified `load`, the two config surfaces (cluster.yaml + ~/.omnigraph/config.yaml), and cluster apply/serve all require 0.7.0.
+metadata:
+ author: ModernRelay
+ version: "0.7.0"
+ repository: https://github.com/ModernRelay/omnigraph
+---
+
+# 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 ` 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`](references/queries.md) and [`references/schema.md`](references/schema.md).
+
+**Comments in `.pg` and `.gq` are `//`, never `#`** (the #1 parse error).
+
+### Read query (`.gq`)
+
+```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: Type` in 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 50` after `return`.
+- **Ranking ops (`nearest`/`bm25`/`rrf`) require a trailing `limit N`** — omitting it is a compile error. They live in `order { }`, not as filters. Scope with `match`/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`).
+
+```gq
+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`: give `from`/`to` slugs; the property block is `{}` when the edge has none.
+
+### Bulk load (JSONL)
+
+```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":{}}
+```
+
+```bash
+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 ` forks a missing `--branch`; bare `load` needs an existing branch. Works local **and** remote.
+- **Date footgun**: `mutate --params` takes ISO strings (`Date` `"2026-04-29"`, `DateTime` `"…T00:00:00Z"`); `load` JSONL takes **integer days since epoch** for `Date` (`20572`) but ISO for `DateTime`.
+
+### Dispatching
+
+```bash
+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 )
+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`** — use `I64`.
+- **A read query needs `match` *and* `return`** (`order`/`limit` optional); a mutation has neither — only `insert`/`update`/`delete`.
+- **`limit` takes an integer literal, not a param** — `limit 50`, never `limit $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`; **aggregates** `count/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):
+
+```pest
+// 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: `` required · `[x]` optional · `` choice · `…` repeatable.
+
+**Global addressing flags**: `--as ` (direct/`--store` writes only — a server resolves the actor from its token), `--server `, `--cluster ` (cluster-managed storage, for maintenance), `--graph ` (selects the graph within a `--server` or `--cluster` scope), `--profile ` (`$OMNIGRAPH_PROFILE`), `--store `. Data commands also take a positional `file://`/`s3://` URI (`--config ` is for `cluster` commands only). Output: `--json`, or reads take `--format `. **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` (alias `read`) `` — a **served stored query** by name (via `--server`/`--profile`); or ad-hoc `[] (--query | -e '')` where `` picks which query in the source. `[--params | --params-file