-
Notifications
You must be signed in to change notification settings - Fork 6
Graph Operations
This guide shows you how to perform graph-lifecycle writes — creating subgraphs and backups, restoring, changing tier, and materializing — through the RoboSystems CQRS command surface and how to monitor long-running operations to completion.
- Overview
- Prerequisites
- Quick Start
- The CQRS Command Surface
- The OperationEnvelope
- Idempotency
- Monitoring Progress with SSE
- Operations Reference
- Worked Example: Create a Backup and Watch It Complete
- Downloading and Unpacking a Backup
- Common Pitfalls
- Related Documentation
- Support
Graph operations are the write half of a CQRS (Command Query Responsibility Segregation) split. Anything that mutates the lifecycle of a graph — its subgraphs, backups, tier, or materialized state — goes through one uniform door:
POST /v1/graphs/{graph_id}/operations/{op_name}
Every one of these commands returns the same JSON shape, an OperationEnvelope, and every one accepts an optional Idempotency-Key header for safe retries. Operations that finish immediately complete inside the response body; operations that hand off to a background worker return an operation_id you can stream over Server-Sent Events (SSE) until they finish.
Reads stay on the other side of the split as ordinary REST GETs — listing subgraphs, listing backups, checking operation status, and health checks are never tunneled through the command surface.
The six core lifecycle operations:
| Operation | Mutates | Sync or async |
|---|---|---|
| create-subgraph | Adds a child graph under a parent | Sync (empty) or async (forked) |
| delete-subgraph | Removes a child graph | Sync |
| create-backup | Produces a stored dump | Async |
| restore-backup | Rebuilds a graph from a dump | Async |
| change-tier | Migrates a graph to a new instance tier | Async |
| materialize | Rebuilds the analytical graph from OLTP/staged data | Async (sync on dry run) |
Two further operations live on the same surface and follow the same contract: delete-graph and change-reporting-style. They are covered briefly in the Operations Reference.
Before starting, ensure you have:
- A running RoboSystems development stack —
just start - Demo credentials —
just demo-userwrites your API key to.local/config.json - A writable graph to act on.
just demo-roboledgerprovisions an entity graph and gives you a realgraph_id. Substitute your owngraph_ideverywherekg1a2b3c4d5appears below. -
jqandcurlon your path
The shared sec repository is read-only — these operations are blocked on it. Use a graph you own.
Read the API key inline from .local/config.json and send it as X-API-Key. The base URL for local testing is always http://localhost:8000.
# Create an empty subgraph (synchronous — completes in the response)
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/create-subgraph" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"name": "dev", "display_name": "Development Environment"}'# Kick off a backup (asynchronous — returns 202 and an operation_id)
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/create-backup" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"backup_format": "full_dump", "retention_days": 30, "encryption": true}'The full surface — every field, every status code — is in the live OpenAPI spec at https://api.robosystems.ai/docs (or http://localhost:8000/docs when running locally). This page carries the concepts and the worked tasks; the spec is the exhaustive reference.
All lifecycle writes share one URL shape. The graph_id is a path parameter — authentication and per-graph access are validated by FastAPI dependencies before the handler runs, so the graph in the URL is always the scope of the command.
POST /v1/graphs/{graph_id}/operations/{op_name}
Funneling every write through one envelope means the same idempotency, auditing, and progress-monitoring machinery applies uniformly. A client that can call one operation can call them all.
Sync vs async. Each operation declares whether it runs to completion in the request or hands off to a background worker:
-
Synchronous operations finish in the response body and return HTTP 200 with
status: "completed". Theresultfield carries the command's output. -
Asynchronous operations enqueue work and return HTTP 202 with
status: "pending". Theresultcarries anoperation_idand amonitoringblock pointing at the SSE endpoint. You watch the tail of the operation over SSE.
Either way, the response is an OperationEnvelope and the operation_id is the through-line that ties the response to its audit log, its SSE stream, and its status snapshot.
Every operation — sync or async, success or failure — returns the same envelope. On the wire the field names are camelCase:
| JSON field | Type | Meaning |
|---|---|---|
operation |
string | The kebab-case operation name (e.g. create-backup) |
operationId |
string | An op_-prefixed identifier. Always present. The correlation key for SSE, /status, and audit logs |
status |
string |
completed (sync done), pending (async accepted), or failed
|
result |
object or null | The command payload. For async ops this is {status, message, monitoring}; may be null while pending |
at |
string | ISO-8601 UTC timestamp with a Z suffix |
createdBy |
string or null | The initiating user id |
idempotentReplay |
boolean |
true when the envelope was served from the idempotency cache (the command did not re-run) |
A representative async response:
{
"operation": "create-backup",
"operationId": "op_01J9ZK7M3QABCDEF...",
"status": "pending",
"result": {
"status": "accepted",
"message": "Backup creation started",
"monitoring": { "sse_endpoint": "/v1/operations/op_01J9ZK7M3QABCDEF.../stream" }
},
"at": "2026-06-11T18:22:05Z",
"createdBy": "user_abc123",
"idempotentReplay": false
}Note: Python field names are snake_case internally, but clients should read the camelCase keys (operationId, createdBy, idempotentReplay) off the wire.
Every operation accepts an optional Idempotency-Key header. It is opt-in protection for retries on a flaky network — without it, a retried POST runs the command twice.
The semantics:
-
Same key + identical body, within the TTL → the cached envelope is replayed with
idempotentReplay: true. The command does not run again. - Same key + a different body → HTTP 409 Conflict. The key is bound to the first request body it saw.
- TTL → 24 hours.
- Scope → keys are user-scoped. You cannot replay another user's operation, even with the same key string.
# Retrying this exact call with the same key replays the cached result
# rather than creating a second backup.
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/create-backup" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Idempotency-Key: backup-2026-06-11-001" \
-H "Content-Type: application/json" \
-d '{"backup_format": "full_dump", "retention_days": 30, "encryption": true}'Important: A natural mistake is to reuse a key while changing the body (for example, bumping retention_days). That returns a 409, not a fresh operation. Pick a new key when the request changes.
Async operations return an operation_id. Two endpoints let you follow it, both mounted under /v1:
GET /v1/operations/{operation_id}/stream # live SSE event stream (text/event-stream)
GET /v1/operations/{operation_id}/status # point-in-time JSON snapshot
Live stream. Connect to /stream to receive events as the operation progresses. The event types are:
| Event type | Meaning |
|---|---|
operation_started |
The worker picked up the operation |
operation_progress |
Incremental progress update (fractional, 0 to 1) |
operation_completed |
Finished successfully; carries the result data |
operation_error |
Failed; carries the error |
operation_cancelled |
The operation was cancelled |
# Stream events live (-N disables curl buffering so events arrive as emitted)
curl -N "http://localhost:8000/v1/operations/op_01J9ZK7M3QABCDEF.../stream" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)"On reconnect, pass from_sequence to replay events you missed rather than starting blind.
Snapshot. If you do not want a long-lived connection, poll /status for a point-in-time JSON view — it returns the operation_id, operation_type, status, timestamps, graph_id, an optional result or error, and a _links.stream pointer back to the live endpoint.
# Point-in-time status (no streaming)
curl -s "http://localhost:8000/v1/operations/op_01J9ZK7M3QABCDEF.../status" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)"Note: SSE streaming consumes no credits, but it is authenticated, enforces ownership (you can only stream your own operations), and is rate-limited to 5 concurrent SSE connections per user.
Each subsection lists the body fields that matter and the relevant guards. The OpenAPI spec at https://api.robosystems.ai/docs is authoritative for the full request and response models.
Adds a child graph under a parent. The resulting subgraph id is {parent_graph_id}_{name} (for example, kg1a2b3c4d5_dev).
-
Body:
name(alphanumeric, 1–20 chars, lowercased),display_name(required), optionaldescription,schema_extensions,subgraph_type(onlystatic), andfork_parent. -
Sync/async: Creating an empty subgraph is synchronous (200). Setting
fork_parent: truecopies the parent's data and runs asynchronously (202 with anoperation_id). - Subgraph count is capped by tier — see Graphs and Multi-Tenancy.
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/create-subgraph" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"name": "dev", "display_name": "Development Environment"}'Removes a child graph. Synchronous.
-
Body:
subgraph_name,force(defaultfalse),backup_first(defaulttrue). - Backs up before deleting unless you opt out.
Produces a stored dump. Asynchronous (202).
-
Body:
backup_format(must befull_dump),backup_type(defaultfull),retention_days(1–2555, default 30),compression(forcedtrue),encryption(defaultfalse), optionalschedule. - Encrypted backups can be restored but not downloaded; unencrypted backups can be downloaded but not restored.
Rebuilds a graph from a dump. Asynchronous (202).
-
Body:
backup_id,create_system_backup(defaulttrue),verify_after_restore(defaulttrue). - Only encrypted backups are restorable. Restore is forbidden on entity graphs — rebuild those with
materializeinstead (see Common Pitfalls).
Migrates a graph to a new instance tier (an EBS volume migration). Asynchronous (202).
-
Body:
new_tier, one ofladybug-standard,ladybug-large,ladybug-xlarge.
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/change-tier" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"new_tier": "ladybug-large"}'Rebuilds the analytical graph (LadybugDB) from OLTP or staged source data. Asynchronous (202), except a dry run which runs synchronously — it returns a completed envelope reporting what it would do, without executing.
-
Body:
source(stagedorextensions),rebuild,force,ignore_errors(defaulttrue),dry_run,materialize_embeddings.
# Materialize an entity graph from the extensions OLTP database
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/materialize" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Idempotency-Key: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"source": "extensions", "rebuild": false}'# Dry run — synchronous, returns a completed envelope with the plan, materializes nothing
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/materialize" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"dry_run": true}'After a materialize completes, the rebuilt graph is queryable — see Querying the Analytical Graph.
Two further operations share the surface:
-
delete-graph(async, 202) — deletes the graph itself. Requiresconfirmin the body to equal thegraph_id, and the caller must be both an org owner and a graph admin. -
change-reporting-style(sync, 200) — sets the active reporting style for the graph byreporting_style_id.
This walks the full async path end to end: kick off a backup, monitor it over SSE, and confirm it landed via the read side.
Backups are asynchronous, so this returns HTTP 202 with a pending envelope and an operation_id.
curl -s -X POST "http://localhost:8000/v1/graphs/kg1a2b3c4d5/operations/create-backup" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Idempotency-Key: backup-2026-06-11-001" \
-H "Content-Type: application/json" \
-d '{"backup_format": "full_dump", "retention_days": 30, "encryption": true}'Output:
{
"operation": "create-backup",
"operationId": "op_01J9ZK7M3QABCDEF...",
"status": "pending",
"result": {
"status": "accepted",
"message": "Backup creation started",
"monitoring": { "sse_endpoint": "/v1/operations/op_01J9ZK7M3QABCDEF.../stream" }
},
"at": "2026-06-11T18:22:05Z",
"createdBy": "user_abc123",
"idempotentReplay": false
}Copy the operationId — every following step keys off it.
Connect to the SSE endpoint and watch the operation move through its event types.
curl -N "http://localhost:8000/v1/operations/op_01J9ZK7M3QABCDEF.../stream" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)"The stream emits operation_started, then one or more operation_progress events, then a terminal operation_completed (or operation_error if something failed). The completion event carries the result data.
If you would rather poll than hold a connection open, hit /status for a point-in-time view:
curl -s "http://localhost:8000/v1/operations/op_01J9ZK7M3QABCDEF.../status" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)"The read side is a plain GET — no envelope, no operation. List the graph's backups to see the new one:
curl -s "http://localhost:8000/v1/graphs/kg1a2b3c4d5/backups" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)"Because you sent an Idempotency-Key in Step 1, re-running that exact request within 24 hours replays the original envelope (idempotentReplay: true) instead of creating a second backup.
Downloading is a read, not an operation. Ask for a time-limited presigned URL, then fetch the file straight from object storage:
# Returns { "download_url": "...", "expires_at": "...", "backup_id": "..." }
curl -s "http://localhost:8000/v1/graphs/kg1a2b3c4d5/backups/bk1a2b3c4d5/download?expires_in=3600" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)"
# Fetch the file — the presigned URL carries its own auth, so no API key here
curl -L -o backup.zip "PASTE_THE_DOWNLOAD_URL"Only unencrypted backups can be downloaded. The URL expires after expires_in seconds (300–86400, default 3600), and each download counts against your graph tier's or repository plan's monthly download allowance.
You don't have to guess: every entry from GET /v1/graphs/{graph_id}/backups carries a download_extension telling you what the download will be and therefore how to unpack it. It is null for encrypted backups, which cannot be downloaded at all.
download_extension |
Filename | Contents |
|---|---|---|
.lbug.zip |
{graph_id}_{timestamp}.lbug.zip |
ZIP archive containing the LadybugDB database file {graph_id}.lbug
|
.lbug.zst |
{graph_id}_{timestamp}.lbug.zst |
A single zstd-compressed LadybugDB database file — how shared repository snapshots (e.g. sec) ship |
Nothing special — unzip on macOS/Linux, or double-click in Finder / Explorer:
unzip kg1a2b3c4d5_20260805_031500.lbug.zip
# -> kg1a2b3c4d5.lbugShared repository snapshots are compressed with zstd, which is not installed by default on macOS or most Linux distributions. Install it first:
# macOS (Homebrew)
brew install zstd
# Debian / Ubuntu
sudo apt-get install zstd
# Amazon Linux / Fedora / RHEL
sudo dnf install zstd
# Windows — via winget, or use 7-Zip which reads .zst natively
winget install Facebook.ZstandardThen decompress from the directory holding the download. On macOS you can jump straight there by right-clicking the enclosing folder → Services → New Terminal at Folder:
# Writes sec_20260805_031500.lbug and keeps the .zst
zstd -d sec_20260805_031500.lbug.zst
# Same, but removes the compressed file afterwards
zstd -d --rm sec_20260805_031500.lbug.zstNo --long flag is needed on your side: the snapshots are compressed with a 128 MB window that plain zstd -d handles. Budget disk for roughly 2× the download size — these files compress at about 2.2×.
The result is an ordinary LadybugDB database file. Drop it into a local stack's database directory and query it directly:
mv sec_20260805_031500.lbug ./data/lbug-dbs/sec.lbug
just lbug-query sec "MATCH (e:Entity) RETURN e.name LIMIT 10"Or open it from Python with the ladybug package (pinned to 0.18.1 in this repo — match the version the snapshot was written with):
import ladybug as lbug
db = lbug.Database("sec_20260805_031500.lbug")
conn = lbug.Connection(db)
result = conn.execute("MATCH (e:Entity) RETURN e.name LIMIT 10")
while result.has_next():
print(result.get_next())
conn.close()create-backup, restore-backup, change-tier, delete-subgraph, and delete-graph all return 403 on shared repositories such as sec. Run lifecycle operations against a graph you own.
Calling restore-backup on an entity graph returns 400 telling you to use materialize instead. Entity graphs are rebuilt from the extensions OLTP database, not restored from a dump.
Only encrypted backups can be restored, and encrypted backups cannot be downloaded. Decide up front: set encryption: true for disaster recovery, or leave it false if you need to pull the dump down.
Any backup_format other than full_dump returns 400.
If retention_days exceeds your tier's maximum, it is silently clamped to the cap rather than rejected. Check the stored value if it matters.
The key is bound to the first body it saw. Change the body and you get a 409 Conflict, not a new operation. Use a fresh key when the request changes.
delete-graph returns 400 unless the confirm field equals the graph_id, and the caller must be both an org owner and a graph admin. This is deliberate friction on an irreversible action.
The backup request model rejects compression: false. Omit it (it defaults to and is forced to true) rather than trying to turn it off.
Wiki Guides:
-
Graphs and Multi-Tenancy - What
graph_id, subgraphs, tiers, and shared repositories are — the things these operations act on -
Querying the Analytical Graph - Query the LadybugDB graph after a
materializerebuilds it -
RoboLedger Operations - The extensions analogue of this surface (
POST /extensions/roboledger/{g}/operations/{op}), using the sameOperationEnvelopeandIdempotency-Keycontract
Codebase Documentation:
- Operations - Business workflow orchestration in codebase
- Graph Routing Middleware - Multi-tenant graph routing in codebase
API Reference:
- API Documentation - Full request/response models and status codes (machine-readable OpenAPI spec)
- RoboSystems API (local): http://localhost:8000/docs (when running locally)
© 2026 RFS LLC
- Quick Start
- Core Concepts
- Architecture Overview
- Bootstrap Guide
- Windows Setup (WSL2)
- Security & Compliance
- Authentication & API Keys
- Graphs & Multi-Tenancy
- Shared Repositories
- Graph Operations
- Querying the Analytical Graph
- Credits & Billing
- AI Operators & MCP
- Pipeline Guide
- Building Custom Integrations
- Extensions Surface Overview
- GraphQL Reads
- RoboLedger Operations
- RoboInvestor Operations
- Connecting QuickBooks Locally