30-minute runbook

30-minute onboarding: sign up, query, backdate a change, record agent memory, and connect an AI client

The commands below run against invariantdb.com. If one fails, use the fix printed next to it or tell us through the signup form.

Requirements: a terminal, Python 3.9+ (or Node 18+), and 30 minutes. You do not need a credit card or a local database. The shell commands use bash syntax. On Windows, use WSL or Git Bash, or set the variables in PowerShell with $env:IDB_KEY = "…".

0—3Sign up and verify email

Go to invariantdb.com/signup.html. Fill in email, name, and password. You get a Cognito account and a tenant.

The site does two things:

  1. Cognito hosted sign-up → verification email → you click the link.
  2. After your first successful sign-in, the browser calls POST /cloud/me/bootstrap with your Cognito JWT. That call is idempotent and creates your tenant record.

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 shows the full graph name (for example a1b2c3_hello) and an api_key that starts with gq_. Copy both. The key is displayed once and cannot be recovered later. It is your data-plane credential: HTTP calls, SDK calls, and MCP connections send it as Authorization: Bearer gq_….
  2. Note the MCP configuration. The same response includes an mcp_config JSON block for MCP clients that accept an HTTP server with headers. You will use it 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. You can repeat the flow safely.
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 get a new key, open API keys in the dashboard and choose Create key. The key is shown once.
Checkpoint (minute 3): You have a tenant, a graph, and a data-plane API key starting with gq_. Set them in your shell for the rest of the runbook:
export IDB_KEY="gq_paste_your_api_key_here"
export IDB_GRAPH="<the full graph name from the response>"

3—5Understand what you got

Three kinds of object, and the URL prefixes you will use.

NounWhat it isIdentifier shape
TenantYour account. One-to-one with your Cognito user. Holds your graphs.tnt_…
GraphA separate dataset with its own storage, history log, and indexes.A tenant prefix plus the name you typed (for example a1b2c3_hello)
API keyA secret bearer credential. Each key belongs to one tenant and can be limited to specific graphs. The server stores only a hash; the key is shown once.gq_ followed by 43 characters

Calls go to one of these URL prefixes:

PrefixAuthWhat it does
/cloud/me/*Cognito JWTControl plane: bootstrap, list graphs, create and revoke keys.
/graphs/{name}/*Bearer gq_…Data plane: Cypher, MCP, search, bulk import.
/graphs/{name}/mcpBearer gq_…MCP endpoint for one graph (JSON-RPC 2.0).

Use the JWT for /cloud/me/* and the gq_ key for /graphs/{name}/*.

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

Python

pip install "https://sdk.invariantdb.com/python/invariantdb-0.8.0-py3-none-any.whl#sha256=15f7ef0dde4f77cb1c3971dbdf750a9f07ef1f9733ffdc166346ae2b108b65dc"

The Python SDK is not published on PyPI. Install it from this URL; pip checks the downloaded file against the SHA-256 in the URL.

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

import os
from invariantdb import Client

db = Client("https://invariantdb.com", api_key=os.environ["IDB_KEY"])
GRAPH = os.environ["IDB_GRAPH"]

result = db.cypher(
    graph=GRAPH,
    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}]

This call:

  1. Authenticated with a bearer token.
  2. Sent POST https://invariantdb.com/graphs/<your graph name>/cypher.
  3. Parsed a Cypher result set.
  4. Showed that the new graph has zero nodes.

Node

npm install https://sdk.invariantdb.com/node/invariantdb-0.8.0.tgz
// hello.mjs (top-level await requires an ES module)
import { Client } from 'invariantdb';

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

const result = await db.cypher(process.env.IDB_GRAPH, 'MATCH (n) RETURN count(n) AS total');
console.log(result.asDicts());
// [ { 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/graphs/$IDB_GRAPH/cypher?format=flat"
# {"columns":["total"],"rows":[[0]],"stats":{"rows_returned":1,"mutations":0,"graph_seq":…},"mutations_applied":0,"last_seq":…}

format=flat returns plain JSON values. Without it, values are tagged, for example {"int":0}. stats can also include a query_id, and a warnings array appears only when the query produced warnings.

Common stumble — 401 Unauthorized on a call you know should work
{"error":"unauthorized","code":"ENG_CLIENT_ERROR","message":"unauthorized"}
Fix
The header value must be Bearer, one space, then the gq_… key. Do not use Token as the scheme, and do not send the key alone. Curl users: put the header in double quotes so your shell keeps the space.
Common stumble — 403 or 404 on the graph name
{"error":"forbidden","code":"ENG_CLIENT_ERROR","message":"forbidden"}
A key limited to one graph returns 403 when the request names another graph. Otherwise, an unknown graph returns 404: {"error":"not_found",…,"message":"not found: graph `hell` not found"}.
Fix
Graph names are case-sensitive and must match exactly. Use the full graph name shown in the dashboard, including the prefix. To list your graphs from the terminal: GET /cloud/me/graphs with your JWT.
Checkpoint (minute 10): The SDK is installed, your key works, and your graph has zero nodes.

10—15Create three people and a company

Cypher drives reads and writes. InvariantDB uses openCypher and adds clauses for valid time and recorded time.

Copy this into your Python file. Each write starts with AT VALID, which sets the date from which the new facts are true:

db.cypher(graph=GRAPH, query="AT VALID '2024-01-01' CREATE (co:Company {name: 'Acme', industry: 'software'})")

for name, role, start in [("Alice", "engineer", "2024-01-15"),
                          ("Bob",   "product",  "2024-03-01"),
                          ("Carol", "engineer", "2025-06-01")]:
    db.cypher(graph=GRAPH, parameters={"name": name, "role": role, "start": start}, query=f"""
        AT VALID '{start}'
        MATCH (co:Company {{name: 'Acme'}})
        CREATE (p:Person {{name: $name, role: $role, startDate: $start}})-[:WORKS_AT {{since: $start}}]->(co)
    """)

# Read it back. Iterating a CypherResult yields column-keyed dicts.
result = db.cypher(graph=GRAPH, 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 to note:

Now try a MERGE. Run it twice; the second run finds the existing node and creates nothing new:

for _ in range(2):
    db.cypher(graph=GRAPH, query="""
        MERGE (d:Person {name: 'Dana'})
          ON CREATE SET d.role = 'design', d.startDate = '2026-02-01'
        RETURN d.name AS name
    """)

r = db.cypher(graph=GRAPH, query="MATCH (d:Person {name: 'Dana'}) RETURN count(d) AS n")
print(r.as_dicts())
# [{'n': 1}]
Common stumble — you get 400 with a Cypher parse error
{"error":"cypher_parse","code":"ENG_CLIENT_ERROR","subcode":"SYNTAX_ERROR",
 "position":{"line":1,"col":42},"message":"parse error: …",
 "details":{"expected":[…],"found":"…"}}
Fix
Often a quoting issue. In a Python f-string, double the braces of Cypher maps ({{ }}). In a shell here-doc, watch for $ expansion inside double quotes. position gives the line and column of the error. You can also paste the query into the console and edit it there.
Checkpoint (minute 15): Your graph now has 5 nodes and 3 edges. You have used CREATE, MATCH, MERGE, RETURN, ORDER BY, and the AT VALID write prefix.

15—20Correct a past value

Valid time and recorded time
Each write records valid time (when the fact is true) and recorded time (when the system learned it). You can query either, and a correction keeps the earlier value.

Right now, Alice's role is engineer, valid from 2024-01-15. Suppose HR corrects the record today: Alice was promoted to senior engineer on 2025-01-01, and the system was never updated.

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

To backdate a write, put AT VALID '<ISO-8601>' before the first clause. First save the current time; Step 4 uses it to query the state before the correction:

from datetime import datetime, timezone

# Save the time before the correction. Step 4 uses it.
before_fix = datetime.now(timezone.utc).isoformat()

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

InvariantDB keeps both values. The engineer value is now valid from 2024-01-15 to 2025-01-01, and the senior engineer value from 2025-01-01 with no end date. Both carry the recorded time of their write.

Step 2 — query the world as of April 2024

result = db.cypher(graph=GRAPH, 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.

Step 3 — query the world as of today

result = db.cypher(graph=GRAPH, 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 recorded before the correction

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

This shows what the system had recorded before the correction.

Step 5 — list the history of Alice's role

result = db.cypher(graph=GRAPH, 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': 1705276800000000000, 'validTo': 1735689600000000000, 'version': 1, 'txnAt': ...}
# {'value': 'senior engineer', 'validFrom': 1735689600000000000, 'validTo': None, 'version': 2, 'txnAt': ...}

Two rows with different valid intervals. txnAt is the recorded time of each version. validFrom, validTo, and txnAt are nanoseconds since the Unix epoch.

Optional — record who made a change

To record who made a change and why, send the write with a provenance object. The Python SDK 0.8.0 does not send provenance; use the HTTP API, or the Node SDK headers option with X-Provenance-* headers. This example updates Bob's role:

curl -fsS -X POST "https://invariantdb.com/graphs/$IDB_GRAPH/cypher?format=flat" \
    -H "Authorization: Bearer $IDB_KEY" \
    -H "Content-Type: application/json" \
    --data '{
      "query": "MATCH (b:Person {name: $name}) SET b.role = $role",
      "parameters": {"name": "Bob", "role": "product lead"},
      "provenance": {"actor": "hr-team", "reason": "promotion"}
    }'

Read it back:

result = db.cypher(graph=GRAPH, query="""
    MATCH (b:Person {name: 'Bob'})
    CALL db.propertyProvenance(id(b), 'role') YIELD value, actor, reason, principal
    RETURN value, actor, reason, principal
""")
for r in result:
    print(r)
# {'value': 'product', 'actor': None, 'reason': None, 'principal': None}
# {'value': 'product lead', 'actor': 'hr-team', 'reason': 'promotion', 'principal': 'apikey:...'}

The server fills in principal with the credential that made the request. See Record who made a change for all fields and headers.

Common stumble — AT VALID in the wrong place
{"error":"cypher_parse","code":"ENG_CLIENT_ERROR","subcode":"SYNTAX_ERROR",
 "message":"parse error: …"}
Fix
In a read, AT VALID and AT RECORDED follow the MATCH pattern. In a write, AT VALID is a prefix before the first clause. The value is an ISO-8601 string. 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(...) (there is no date() function).
Checkpoint (minute 20): You have written a backdated correction, queried valid time in the past and today, queried recorded time before the correction, and listed the history of a property.

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

An episode is one observed event in the work of an AI agent, such as a user turn, a tool call, or a summary. Each episode has a session ID (usually a conversation ID), a kind, text, an optional payload string, and, when the server has an embedding model configured, an embedding of the text.

Record two episodes on a conversation

Each call takes (sessionId, kind, text [, payload]). payload is optional. Pass a string; to store structured data, pass a JSON string such as '{"topic":"pricing"}'. A map or a list is refused. The procedure yields eventId and ts (nanoseconds since the Unix epoch).

db.cypher(graph=GRAPH, 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 are now linked from a :Session {id: 'conv-2026-07-09-001'} node by [:HAD_EVENT] edges. Each Event has an id, a ts in nanoseconds, the text, and, when the server has an embedding model configured, a vector in _embedding_v1.

Replay them in order

result = db.cypher(graph=GRAPH, 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)

db.replayEpisodes returns a session's events in time order. Use it to reconstruct a conversation, or to start a new session with a summary of the last one.

Vector search across conversations

gds.vectorSearch takes (label, property, queryVector [, k [, metric]]); the query must be a vector. gds.embedText turns text into a vector with the server's embedding model:

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

db.recordEpisode stores an embedding of the text in Event._embedding_v1 when the server has an embedding model configured. The search index is built on the first search.

Common stumble — UNKNOWN_YIELD_COLUMN
{"error":"cypher_exec","code":"ENG_CLIENT_ERROR","subcode":"UNKNOWN_YIELD_COLUMN",
 "message":"execute error: UNKNOWN_YIELD_COLUMN: requested `content` is not a column produced by this procedure (available: eventId, kind, ts, text, payload) …"}
Fix
Unknown YIELD columns are rejected rather than ignored, so match the exact column set. db.replayEpisodes yields eventId, kind, ts, text, payload. db.recordEpisode yields eventId, ts (with episodeId as an alias for eventId). To list procedure signatures, run CALL db.listProcedures() YIELD name, signature, description.
Checkpoint (minute 25): Your graph now stores agent memory. You can record episodes, replay them in order, and search them by meaning.

25—30Connect an AI client over MCP

InvariantDB runs an MCP server for each graph at https://invariantdb.com/graphs/<your graph name>/mcp. MCP clients that support the HTTP transport connect with this URL and get access to 28 tools: Cypher queries, full-text and vector search, schema and index listing, node and edge lookup, agent memory (remember, recall, recall as of a date, record and replay episodes), documents, belief lineage, property provenance, and compliance procedures.

Path A — clients that support OAuth sign-in

In your MCP client, add a remote (HTTP) MCP server with the URL https://invariantdb.com/graphs/<your graph name>/mcp and no Authorization header. When the approval page opens in your browser, paste your gq_ key and approve. See Connect with OAuth sign-in for details.

Then ask the assistant:

“Using the invariantdb tools, count the people in my graph and list their roles.”

The assistant calls cypher_query against your graph and replies with the result.

Path B — clients configured with a JSON file

Clients that read MCP servers from a JSON configuration file can send your API key in a header. Use the mcp_config block from the create-graph response, or add an entry like this one:

{
  "mcpServers": {
    "invariantdb": {
      "type": "http",
      "url": "https://invariantdb.com/graphs/<your graph name>/mcp",
      "headers": {
        "Authorization": "Bearer gq_paste_your_api_key_here"
      }
    }
  }
}

The file name and field names differ between clients; see the MCP page for details. Restart or reload the client. You should see the invariantdb tools listed.

Path C — skip MCP and open the decision demo

If you do not have an AI client on hand, open /decision-demo.html. It shows a fictional underwriting decision with its history and lets you move back in time to see what was recorded at each point.

Common stumble — the client cannot connect to the invariantdb server
Three causes, in order of likelihood:
  1. URL. The URL must include your full graph name: https://invariantdb.com/graphs/<your graph name>/mcp.
  2. JSON syntax (configuration-file clients). Missing comma, unclosed brace, or curly quotes pasted from a document. Validate the file with python -m json.tool < <path to the file>.
  3. Header value. Literally Bearer <space> gq_…. If you paste only the key without the prefix, the server returns 401 and the client shows a connection error.
Checkpoint (minute 30): You have an MCP connection between an AI client and your graph. The client can query the graph, record episodes, and look up decisions.

Frequently asked, in the first hour

How is my data stored?

Each graph has its own storage, history log, and indexes. Graphs created from the dashboard belong to your tenant. For security documentation, contact us.

How much does this cost?

InvariantDB is in design-partner mode: early customers run without billing. See Pricing for current terms.

Where do I get support?

For anything: reply to your welcome email.

How do I export or back up my graph?

Managed cloud: ask support for an export of your graph. Self-hosted: an admin can run POST /graphs/{name}/backup, which writes a compressed archive to a directory on the server; see Backup and restore.

What if I hit rate limits?

If the server is limiting request rate, you get 429 Too Many Requests with a Retry-After header (seconds) and retry_after_ms in the body. Wait that long, then retry. Reduce the number of requests you send at the same time from one client.

Which SDK method should I use for X?

db.cypher(graph, query, parameters) covers most work. Use db.search.fulltext and db.search.vector for search without Cypher, and db.cursor.stream(graph, query) to iterate large results in batches. Create graphs in the dashboard. See the SDK reference.

Does the SDK do retries?

No. The Python and Node SDKs 0.8.0 do not retry. Retry 429 and 503 responses yourself after the Retry-After interval, with jitter. Do not retry other 4xx responses.

Can I run Cypher I already wrote for Neo4j?

Mostly, yes. InvariantDB targets openCypher core. Differences: (a) we add AT VALID, AT RECORDED, AS OF, and OVER VALID/RECORDED BETWEEN for reads, and an AT VALID prefix for backdated writes; (b) our procedure catalog uses db.* and gds.* namespaces but the specific procedures differ; (c) we do not support APOC. See the Cypher reference for the differences.

How do I rotate an API key?

Create a new key in the dashboard's API keys panel, switch your callers to it, then revoke the old key in the same panel (DELETE /cloud/me/api-keys/{id}).

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

The MCP server returns the parse error message for invalid queries, and clients often correct the query on a retry. If it keeps happening, add a short system-prompt hint listing the temporal syntax (AT VALID, AT RECORDED) and the exact YIELD columns of the procedures you use. CALL db.listProcedures() returns the current procedure catalog.

Where to go next

Pick the path closest to what you are building.

If you are building…Read nextWhy
An agent that remembers AI memory guide → MCP tools Episodes, belief revision, and salience decay, with Python examples.
A compliance / audit-trail system Compliance dashboard → Regulated industries How the audit chain, WORM mode, and erasure procedures relate to common regulations.
A general graph app Cypher reference → SDK reference The query language and the client libraries.

Start free   Open the decision demo   Full docs

Last reviewed: 2026-09-25. If a command on this page does not work as printed, tell us through the signup form and we will fix the page.