30-minute runbook

From cold sign-up to your first bitemporal query, agent memory, and a live AI connection — in 30 minutes.

Zero fluff. Every command below is written against the current production binary on the box that answers invariantdb.com. If something fails, the failure output is printed next to the fix — and if the exact behavior drifts under you, please tell us at the signup form so we can pin the next revision.

Requirements: a terminal, Python 3.9+ (or Node 18+), and 30 minutes. You do not need a credit card. You do not need to install a database. If you are on Windows, use PowerShell or WSL — every command below is copy-pasteable in either.

0—3Sign up and verify email

Go to invariantdb.com/signup.html. Fill in email + name + password. You will be issued a Cognito account and a tenant.

Under the hood the site does two things:

  1. Cognito hosted sign-up → verification email → you click the link.
  2. After first successful sign-in, the browser calls POST /cloud/me/bootstrap with your Cognito JWT. That call is idempotent and creates your tenant record (region us by default; add ?region=eu if you need EU residency).

You will land on the dashboard. On the dashboard, do two things:

  1. Create a graph. Type hello as the name and press Create graph. The response contains an api_key that starts with gq_. Copy this key into a safe place immediately — it is displayed once and cannot be recovered later. It is your data-plane credential; every subsequent HTTP call, SDK call, and MCP connection uses it as Authorization: Bearer gq_….
  2. Note your MCP config block. The same response shows a copy-paste JSON for Claude Desktop and Cursor. You will use this at minute 25.
Common stumble — verification email doesn't arrive
Check spam. The sender is no-reply@verificationemail.com (AWS Cognito default). If it is still absent after 3 minutes, click Resend on the sign-in screen or come back to the signup form — the flow is fully idempotent.
Common stumble — API key on second graph shows null
The API key is only returned on first creation of a graph name. If you re-run the same create against an existing name, the response is a 200 with "already_existed": true and "api_key": null. To mint a new key, use the API keys panel in the dashboard or call POST /cloud/tenants/{id}/api-keys with your control-plane token.
Checkpoint (minute 3): You have a tenant, a graph named hello, and a data-plane API key starting with gq_. Set it in your shell so we can use it for the rest of the runbook:
export IDB_KEY="gq_paste_your_api_key_here"
export IDB_GRAPH="hello"

3—5Understand what you got

Three nouns, three verbs. Read this once and you will never be confused by the API surface again.

NounWhat it isIdentifier shape
TenantYour account. One-to-one with your Cognito user. Owns billing, region, all graphs.tnt_…
GraphA logically isolated dataset. Storage, indexes, audit chain, WAL — all scoped per graph.Human-readable name (e.g. hello)
API keyA signed bearer credential. Scoped to one or more graphs. Roles: tenant_admin, graph_admin, read_write, read_only.gq_… (32-char base62)

Every call goes to one of three URL prefixes:

PrefixAuthWhat it does
/cloud/me/*Cognito JWTControl plane. Bootstrap, list graphs, create/rotate keys.
/e/graphs/{name}/*Bearer gq_…Data plane. Cypher, MCP, search, bulk import, backup.
/e/mcpBearer gq_…Model Context Protocol endpoint (JSON-RPC 2.0). Used by AI clients.

The critical distinction: the JWT from Cognito is only for administering your tenant. It cannot run Cypher. The gq_… key is only for the data plane. It cannot mint other keys. Mixing them silently 401s.

Common stumble — using the JWT to run a query
$ curl -H "Authorization: Bearer eyJraWQ..." https://invariantdb.com/e/graphs/hello/cypher \
    -d '{"query":"MATCH (n) RETURN count(n)"}'
{"error":{"code":"UNAUTHORIZED","message":"invalid credential for data plane"}}
Fix
Use the gq_… key for anything under /e/. Use the JWT only for /cloud/me/*.

5—10Install the SDK and count the nodes in your empty graph

Python

pip install invariantdb

Then, in a Python REPL or a file called hello.py:

from invariantdb import Client

db = Client("https://invariantdb.com", api_key="gq_paste_your_api_key_here")

result = db.cypher(
    graph="hello",
    query="MATCH (n) RETURN count(n) AS total"
)
# `rows` are positional lists that line up with `columns`.
# Call `.as_dicts()` for keyed output.
print(result.columns, result.rows)
# -> ['total'] [[0]]
print(result.as_dicts())
# -> [{'total': 0}]

You just:

  1. Authenticated with a bearer token,
  2. Hit POST https://invariantdb.com/e/graphs/hello/cypher,
  3. Parsed a Cypher result set,
  4. Verified the graph has zero nodes (which is correct — you just made it).

Node

npm install invariantdb
import { Client } from 'invariantdb';

const db = new Client({ url: 'https://invariantdb.com', apiKey: 'gq_paste_your_api_key_here' });

const { rows } = await db.cypher({
    graph: 'hello',
    query: 'MATCH (n) RETURN count(n) AS total'
});
console.log(rows);
// [{ total: 0 }]

curl (equivalent)

curl -fsS -X POST -H "Authorization: Bearer $IDB_KEY" \
    -H "Content-Type: application/json" \
    --data '{"query":"MATCH (n) RETURN count(n) AS total"}' \
    "https://invariantdb.com/e/graphs/$IDB_GRAPH/cypher"
# {"rows":[{"total":0}],"columns":["total"],"stats":{...}}
Common stumble — 401 Unauthorized on a call you know should work
{"error":{"code":"UNAUTHORIZED","message":"missing or invalid Authorization header"}}
Fix
The header value must be literally Bearer <space> gq_…. Case-sensitive scheme. Exactly one space. Not bearer. Not Token. Not just gq_…. Curl users: put the header in double quotes so your shell doesn't eat the space.
Common stumble — 404 on the graph name
{"error":{"code":"GRAPH_NOT_FOUND","message":"graph 'hell' not found"}}
Fix
Graph names are case-sensitive and must match exactly. If you created hello in the dashboard, use hello, not Hello. To list your graphs from the terminal: GET /cloud/me/graphs with your JWT.
Checkpoint (minute 10): SDK installed. You can prove your key works. You have counted zero nodes. Now let us make some.

10—15Create three people and a company

Cypher — the same query language you already know from Neo4j — drives every write and read. InvariantDB adds bitemporal extensions on top; the core language is unchanged.

Copy-paste this into your Python file:

db.cypher(graph="hello", query="""
    CREATE (a:Person {name: 'Alice', role: 'engineer',      startDate: date('2024-01-15')})
    CREATE (b:Person {name: 'Bob',   role: 'product',       startDate: date('2024-03-01')})
    CREATE (c:Person {name: 'Carol', role: 'engineer',      startDate: date('2025-06-01')})
    CREATE (co:Company {name: 'Acme', industry: 'software'})
    CREATE (a)-[:WORKS_AT {since: date('2024-01-15')}]->(co)
    CREATE (b)-[:WORKS_AT {since: date('2024-03-01')}]->(co)
    CREATE (c)-[:WORKS_AT {since: date('2025-06-01')}]->(co)
    RETURN count(*) AS created
""")

# Read it back — iterating a CypherResult yields column-keyed dicts.
result = db.cypher(graph="hello", query="""
    MATCH (p:Person)-[:WORKS_AT]->(co:Company {name: 'Acme'})
    RETURN p.name, p.role, p.startDate
    ORDER BY p.startDate
""")
for r in result:
    print(r)
# {'p.name': 'Alice', 'p.role': 'engineer', 'p.startDate': '2024-01-15'}
# {'p.name': 'Bob',   'p.role': 'product',  'p.startDate': '2024-03-01'}
# {'p.name': 'Carol', 'p.role': 'engineer', 'p.startDate': '2025-06-01'}

Two things worth noticing:

Now try a MERGE. Run it twice — the second run should not create anything new:

for _ in range(2):
    r = db.cypher(graph="hello", query="""
        MERGE (d:Person {name: 'Dana'})
          ON CREATE SET d.role = 'design', d.startDate = date('2026-02-01')
        RETURN d.name AS name
    """)
    print(r.stats)
# {'nodesCreated': 1, 'propertiesSet': 2, ...}
# {'nodesCreated': 0, 'propertiesSet': 0, ...}
Common stumble — you get 400 with a Cypher parse error
{"error":{"code":"CYPHER_PARSE_ERROR","message":"expected '{' at line 1, column 42",
          "position":{"line":1,"column":42}}}
Fix
Almost always a shell-escaping issue. In a Python triple-quoted string, use straight ' or ". In a shell here-doc, watch out for $ expansion inside double quotes. If you cannot narrow it down, drop the query into console and iterate there — it renders the parse error at the exact character offset.
Checkpoint (minute 15): Your graph now has 5 nodes and 3 edges. You have exercised CREATE, MATCH, MERGE, RETURN, ORDER BY, date literals, and stats. This is enough Cypher to build a real application.

15—20The bitemporal WOW: rewrite the past without a migration

This is what makes InvariantDB different.
In Neo4j, MongoDB, or Postgres, when you write UPDATE person SET role = 'senior engineer', the old value is gone. You cannot ask “what did we think Alice's role was on March 5?” without maintaining audit tables by hand. Here, every write carries two timestamps — valid time (when the fact is true in the real world) and recorded time (when the system learned it). You can query either axis, and you can rewrite valid time without losing the record of what the system used to think.

Let us walk it. Right now, Alice's role is engineer as of 2024-01-15. Suppose HR corrects the record on today: “actually, Alice was promoted to senior engineer on 2025-01-01 — we just forgot to update the system.”

Step 1 — write the correction, valid-from 2025-01-01

Bitemporal clauses attach to the MATCH pattern as a prefix (AT VALID '<ISO-8601>') — not as a trailing SET modifier. The write below opens a new valid-time interval starting 2025-01-01 for Alice's role:

db.cypher(graph="hello", query="""
    AT VALID '2025-01-01T00:00:00Z'
    MATCH (a:Person {name: 'Alice'})
    SET a.role = 'senior engineer'
""")

InvariantDB does not overwrite Alice's role. It closes the old row (role=engineer valid [2024-01-15, 2025-01-01)) and opens a new row (role=senior engineer valid [2025-01-01, ∞)). Both rows share a recorded_at of “right now” — because that is when the system learned about the correction.

Step 2 — query the world as of April 2024

result = db.cypher(graph="hello", query="""
    MATCH (a:Person {name: 'Alice'}) AT VALID '2024-04-15T00:00:00Z'
    RETURN a.name, a.role
""")
print(result.as_dicts())
# [{'a.name': 'Alice', 'a.role': 'engineer'}]

Alice was an engineer in April 2024. That was true then and it is still true now.

Step 3 — query the world as of today

result = db.cypher(graph="hello", query="""
    MATCH (a:Person {name: 'Alice'})
    RETURN a.name, a.role
""")
print(result.as_dicts())
# [{'a.name': 'Alice', 'a.role': 'senior engineer'}]

Step 4 — query what the system believed yesterday (before the correction)

from datetime import datetime, timezone, timedelta
# ISO-8601 with the trailing 'Z' for UTC — no timezone offset.
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ')

result = db.cypher(graph="hello", query=f"""
    MATCH (a:Person {{name: 'Alice'}}) AT RECORDED '{yesterday}'
    RETURN a.name, a.role
""")
print(result.as_dicts())
# [{'a.name': 'Alice', 'a.role': 'engineer'}]

Yesterday, we still thought Alice was an engineer — because the correction had not been recorded yet. This is what knowledge time gives you: an unforgeable answer to “what did we know when we acted?”

Step 5 — walk the full history of Alice's role

result = db.cypher(graph="hello", query="""
    MATCH (a:Person {name: 'Alice'})
    CALL db.propertyHistory(id(a), 'role') YIELD value, validFrom, validTo, version, txnAt
    RETURN value, validFrom, validTo, version, txnAt
    ORDER BY validFrom
""")
for r in result:
    print(r)
# {'value':'engineer',        'validFrom':'2024-01-15T00:00:00Z','validTo':'2025-01-01T00:00:00Z','version':1,'txnAt':...}
# {'value':'senior engineer', 'validFrom':'2025-01-01T00:00:00Z','validTo':null,                    'version':2,'txnAt':...}

Two rows. Different valid intervals. txnAt is the transaction time (when the system learned it) — the recorded-time axis. The audit trail is the schema.

Common stumble — AT VALID in the wrong place
{"error":{"code":"CYPHER_PARSE_ERROR",
          "message":"unexpected token near AT VALID"}}
Fix
AT VALID and AT RECORDED attach to the MATCH pattern (or sit as a leading prefix on a write). The literal is a string in ISO-8601 UTC form. Correct: MATCH (a:Person) AT VALID '2024-04-15T00:00:00Z' WHERE a.name = 'Alice'. Wrong: MATCH (a:Person) WHERE a.name = 'Alice' AT VALID ... or AT VALID date(...).
Checkpoint (minute 20): You have written a backdated correction, queried the world at four different times (valid past, valid now, recorded past, recorded now), and walked the full lineage of a property. Every one of those queries returned the correct answer without you writing a single migration or audit table.

20—25Agent memory: record an episode, replay it

An episode is a first-class primitive: a single observed event in the life of an AI agent (a user turn, a tool call, a summarization, an internal reflection). Each episode carries a subject (usually a conversation ID), a kind, text content, structured metadata, and — automatically — an embedding for later hybrid recall.

Record two episodes on a conversation

Each call takes (sessionId, kind, text [, payload]). payload is optional structured metadata — pass a JSON-shaped map as the fourth argument. The procedure YIELDS eventId and ts (nanosecond epoch).

db.cypher(graph="hello", parameters={
    "conv": "conv-2026-07-09-001",
    "text1": "User asked about pricing for the enterprise plan.",
    "text2": "Agent replied with a summary of tiers; user asked to schedule a call.",
}, query="""
    CALL db.recordEpisode($conv, 'observation', $text1) YIELD eventId AS e1, ts AS t1
    CALL db.recordEpisode($conv, 'observation', $text2) YIELD eventId AS e2, ts AS t2
    RETURN e1, t1, e2, t2
""")

Two :Event nodes now hang off a :Session {id: 'conv-2026-07-09-001'} node via [:HAD_EVENT] edges. Each Event carries an id, a nanosecond ts, the raw text, and (when embeddings are configured) a vector under _embedding_v1.

Replay them chronologically

result = db.cypher(graph="hello", parameters={"conv": "conv-2026-07-09-001"}, query="""
    CALL db.replayEpisodes($conv, 50)
    YIELD eventId, kind, ts, text, payload
    RETURN kind, text, ts, payload
    ORDER BY ts
""")
for r in result:
    print(r)

This is the primitive that lets an agent reconstruct the exact conversation state from any point in time — useful for “why did the agent make that decision at 4:12 PM?” postmortems and for warm-starting a new session with a summary of the last one.

Vector search across all conversations

gds.vectorSearch takes (label, prop, query [, k]) — a label + property pair, not a named index. db.recordEpisode writes into Event._embedding_v1, so that is what we search:

result = db.cypher(graph="hello", parameters={
    "q": "customer wanted to schedule a call about enterprise pricing"
}, query="""
    CALL gds.vectorSearch('Event', '_embedding_v1', $q, 5)
    YIELD nodeId, score
    MATCH (e:Event) WHERE id(e) = nodeId
    RETURN e.id, e.text, score
    ORDER BY score DESC
""")
for r in result:
    print(r)

This queries the same Events with semantic similarity. The vector index is built and stamped the first time you call recordEpisode — the default embedding model is applied unless you have configured one explicitly.

Common stumble — UNKNOWN_YIELD_COLUMN
{"error":{"code":"UNKNOWN_YIELD_COLUMN",
          "message":"procedure 'db.replayEpisodes' does not YIELD 'content'; available: eventId, kind, ts, text, payload"}}
Fix
Since 2026-07-01 the executor is loud-by-default on unknown YIELD columns (GQ_UNKNOWN_YIELD_HARD_ERROR). Match the exact column set. db.replayEpisodes yields eventId, kind, ts, text, payload. db.recordEpisode yields eventId, ts (with episodeId as a back-compat alias for eventId). To list every procedure signature live, run CALL db.listProcedures() YIELD name, signature, description.
Checkpoint (minute 25): Your graph is now doing double duty as an agent-memory store. You can record episodes, replay them in order, and semantic-search across them. Any AI application built on this graph can now answer “what did I see, when, and what did it mean.”

25—30Connect an AI over MCP

InvariantDB ships an MCP server. Any MCP-compatible client (Claude Desktop, Cursor, Continue, VS Code with the MCP extension) can attach in one config block and get access to 14 tools: Cypher query, fulltext + vector search, schema introspection, episode record/replay, belief-revision primitives, provenance queries, audit replay.

Path A — Claude Desktop

Open the config file:

Add (or merge) this block. HTTP MCP transport requires a recent Claude Desktop build (2025+); if your client only accepts stdio servers, the MCP catalog page lists a small stdio bridge you can shim in front:

{
  "mcpServers": {
    "invariantdb": {
      "type": "http",
      "url": "https://invariantdb.com/e/mcp",
      "headers": {
        "Authorization": "Bearer gq_paste_your_api_key_here"
      }
    }
  }
}

Restart Claude Desktop. Click the tools icon (looks like a small plug). You should see 14 tools appear under “invariantdb.” Ask Claude:

“Using the invariantdb tool, count the people in the hello graph and list their roles.”

Claude will call cypher_query, run the query against your graph, and reply with structured output. You just gave an LLM a durable, bitemporal memory in six lines of config.

Path B — Cursor / Continue / VS Code

Same shape, different config file. See the MCP catalog page for the exact path per client. The endpoint (https://invariantdb.com/e/mcp), the auth header shape, and the tool surface are identical.

Path C — skip MCP and drive the decision demo

If you do not have an AI client on hand, open /decision-demo.html. It renders a live workflow — a synthetic underwriting decision that runs Cypher against a real graph, shows the bitemporal audit trail, and lets you scrub back in time to see what the model believed at any point. It is the same story you just walked minute-by-minute, in a UI.

Common stumble — Claude says “I could not connect to the invariantdb server”
Two causes, in order of likelihood:
  1. JSON syntax. Missing comma, unclosed brace, curly-quote pasted from a doc. Validate with python -m json.tool < ~/Library/Application\ Support/Claude/claude_desktop_config.json.
  2. Header value. Same rule as everywhere else — literally Bearer <space> gq_…. If you accidentally pasted only the key with no prefix, Claude will silently 401 and show you the connect error.
Checkpoint (minute 30): You have a running MCP connection between an LLM client and your InvariantDB graph. Any future prompt can query the graph, record new episodes, or trace decisions.

Frequently asked, in the first hour

Is my data isolated from other tenants?

Yes. Every graph lives in its own directory tree on disk (own snapshots, own WAL, own audit chain, own indexes). Bearer keys are scoped to a tenant, and the router refuses cross-tenant access at the HTTP layer before any Cypher parses. Multi-tenant isolation is enforced at three layers: URL routing, key scope check, and per-graph storage engine instance. For deep-inspection material (SOC 2 report, penetration-test findings, data-residency evidence for EU tenants) — those are gated to paid tiers and available under NDA; reply to your welcome email to request them. Shared compute (default free / paid tiers) vs. dedicated tenancy (enterprise) is called out on the Pricing page.

How much does this cost?

Free tier: 1 graph, 100 MB storage, 100k queries per month, community support. Paid tiers start at $99/month for 10 graphs and 10 GB. Regulated / enterprise pricing is bespoke (compliance bundle export, WORM mode, DP mode, dedicated tenancy). See Pricing. No credit card required to start — you can build a complete demo on the free tier.

Where do I get support?

For anything: reply to your welcome email — it comes from a real founder, not a ticket queue. For deep questions, open a thread in the community forum linked from the dashboard footer. For paid tiers, we schedule a shared Slack channel.

How do I export or back up my graph?

Two paths. (1) POST /e/graphs/{name}/backup returns a portable, cryptographically stamped tar bundle (canonical .dat files + sidecars + lineage manifest + schema). Verify with invariantdb backup verify <file.tar>. (2) For compliance-grade export (audit chain + ACL events + crypto-shred receipts), use POST /e/graphs/{name}/compliance/export. Both are described in Backup, restore, DR.

What if I hit rate limits?

You will see 429 Too Many Requests with a Retry-After header. Default limits are per-key, per-minute; the SDK backs off automatically. To raise them, upgrade the key's tier from the dashboard or update the rateLimits block via the API keys panel. On free tier, the ceiling is 100 requests/minute.

Which SDK method should I use for X?

Ninety-percent rule: db.cypher(graph, query, parameters) is the workhorse. Everything else is a shorthand. Use db.search.fulltext and db.search.vector for search when you do not want to write Cypher. Use db.graphs.ensure(name) for idempotent create. Use db.stream(graph, query) for result sets bigger than 100k rows — it returns an iterator so you never load the whole result into memory. See SDK reference for the full table.

Does the SDK do retries?

Yes. Both the Python and Node clients retry on 429, 502, 503, 504 with exponential backoff and full jitter. Retries do not happen on 4xx other than 429; those are your bug, not ours. Override with Client(…, retry_max_attempts=5, retry_max_delay_secs=30).

Can I run Cypher I already wrote for Neo4j?

Mostly, yes — we target openCypher core. Differences: (a) we add AT VALID, AT RECORDED, OVER VALID BETWEEN, and VALID FROM for bitemporal; (b) our procedure catalog uses db.* and gds.* namespaces but the specific procedures differ; (c) we do not support APOC. See Cypher reference for the exact deltas.

How do I rotate an API key?

Dashboard → API keys panel → delete the old key and issue a new one via POST /cloud/tenants/{id}/api-keys. A dedicated rotate-with-grace endpoint is planned; today, the safe path is to issue the new key first, roll callers to it, then delete the old key with DELETE /cloud/tenants/{id}/api-keys/{key_id}. Every key issue + delete is stamped in the audit chain.

My AI client keeps calling cypher_query with syntactically wrong queries — what do I do?

The MCP server returns errors with the exact position of the parse failure, and Claude/Cursor generally repair on the second attempt. If you see it happen repeatedly, add a short system-prompt hint listing the bitemporal syntax (AT VALID, AT RECORDED) and the exact YIELD columns of the procedures you use. You can also use db.listProcedures() to give the model a live catalog.

Where to go next

Three concrete paths. Pick the one closest to what you are trying to build.

If you are building…Read nextWhy
An agent that remembers AI memory guideMCP catalog The full episodic + belief-revision + salience-decay pattern, end-to-end Python.
A compliance / audit-trail system Compliance dashboardRegulated industries Every guarantee (WORM, crypto-shred, tamper-evident chain) mapped to the regulation it satisfies.
A general graph app Cypher referenceSDK reference The core language + client library, with every procedure and shortcut documented.

Start free   Watch the decision demo   Full docs

Runbook version: 2026-07-09. Written against the current production binary. If a command in this page does not work exactly as printed, that is a bug — tell us at the signup form and we will fix it in the next revision.