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 = "…".
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:
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:
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_….mcp_config JSON block for MCP clients that accept an HTTP server with headers. You will use it at minute 25.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.
"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.
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>"
Three kinds of object, and the URL prefixes you will use.
| Noun | What it is | Identifier shape |
|---|---|---|
| Tenant | Your account. One-to-one with your Cognito user. Holds your graphs. | tnt_… |
| Graph | A separate dataset with its own storage, history log, and indexes. | A tenant prefix plus the name you typed (for example a1b2c3_hello) |
| API key | A 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:
| Prefix | Auth | What it does |
|---|---|---|
/cloud/me/* | Cognito JWT | Control plane: bootstrap, list graphs, create and revoke keys. |
/graphs/{name}/* | Bearer gq_… | Data plane: Cypher, MCP, search, bulk import. |
/graphs/{name}/mcp | Bearer gq_… | MCP endpoint for one graph (JSON-RPC 2.0). |
Use the JWT for /cloud/me/* and the gq_ key for /graphs/{name}/*.
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:
POST https://invariantdb.com/graphs/<your graph name>/cypher.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 -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.
{"error":"unauthorized","code":"ENG_CLIENT_ERROR","message":"unauthorized"}
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.
{"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"}.
GET /cloud/me/graphs with your JWT.
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:
AT VALID prefix sets when each fact became true; without it, a write is valid from the moment it is recorded.mutations_applied. The response does not report per-kind counts such as nodes created or properties set. Known limitation: a MERGE that only matches an existing node still reports one mutation, so the count does not show whether a node was created.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}]
{"error":"cypher_parse","code":"ENG_CLIENT_ERROR","subcode":"SYNTAX_ERROR",
"position":{"line":1,"col":42},"message":"parse error: …",
"details":{"expected":[…],"found":"…"}}
{{ }}). 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.
AT VALID write prefix.
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.
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.
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.
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'}]
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.
roleresult = 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.
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.
AT VALID in the wrong place{"error":"cypher_parse","code":"ENG_CLIENT_ERROR","subcode":"SYNTAX_ERROR",
"message":"parse error: …"}
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).
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.
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.
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.
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.
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) …"}
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.
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.
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.
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.
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.
https://invariantdb.com/graphs/<your graph name>/mcp.python -m json.tool < <path to the file>.Bearer <space> gq_…. If you paste only the key without the prefix, the server returns 401 and the client shows a connection error.Each graph has its own storage, history log, and indexes. Graphs created from the dashboard belong to your tenant. For security documentation, contact us.
InvariantDB is in design-partner mode: early customers run without billing. See Pricing for current terms.
For anything: reply to your welcome email.
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.
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.
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.
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.
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.
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}).
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.
Pick the path closest to what you are building.
| If you are building… | Read next | Why |
|---|---|---|
| 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.