-
Notifications
You must be signed in to change notification settings - Fork 6
Extensions Surface Overview
The extensions surface is the read/write API layer for RoboSystems product extensions (RoboLedger and RoboInvestor). It sits beside the core /v1 platform API under a single /extensions/* prefix, and everything on it is graph-scoped at the URL.
- What the Extensions Surface Is
- The Three Sub-Surfaces
- Graph-Scoping at the URL
- Auth Runs Before the Handler
- Feature Flags and the Dynamically Built Schema
- The OperationEnvelope Contract
- One Invariant: The Ops Layer Is the Single Source of Truth
- Worked Examples
- Gotchas
- Related Documentation
- Support
The core platform API lives under /v1 and covers graphs, billing, auth, and shared repositories. Product extensions — RoboLedger (accounting) and RoboInvestor (investment management) — are served separately under /extensions/*. The split is deliberate: the platform API manages identity, infrastructure, and metadata, while the extensions surface is where you read and write the domain data for a specific graph.
The extensions surface has a single organizing rule: the graph (tenant) is always in the URL path. Every route below is scoped to a graph_id, and FastAPI validates the user's access to that graph before any handler runs.
Everything on the extensions surface falls into one of three sub-surfaces, split by transport rather than by HTTP verb. Reads go through GraphQL, writes go through named command operations, and graph-backed analytics go through named view operations.
| Sub-surface | Method + Path | Read/Write | Backs onto |
|---|---|---|---|
| GraphQL typed reads | POST /extensions/{graph_id}/graphql |
Read | PostgreSQL extensions OLTP database |
| Command writes | POST /extensions/{roboledger|roboinvestor}/{graph_id}/operations/{op_name} |
Write | PostgreSQL extensions OLTP database |
| Analytical view operations | POST /extensions/roboledger/{graph_id}/operations/{view_name} |
Read | Materialized LadybugDB XBRL hypercube |
A few details worth pinning down:
- GraphQL typed reads are served by a single Strawberry endpoint per graph. The schema is composed dynamically per deployment (see Feature Flags). In dev, this endpoint renders GraphiQL with introspection.
-
Command writes are explicit, named operations (
close-period,share-report,create-security) rather than resource CRUD. They match the multi-step domain operations they actually perform. Each returns anOperationEnvelope. -
Analytical view operations share the same envelope contract as command writes but are read-only and routed to the materialized graph rather than the OLTP database.
build-fact-grid(multi-dimensional pivot tables over the XBRL hypercube) is the canonical example. These are gated independently of the OLTP domains, so a deployment without RoboLedger tenants can still use them.
For the full, machine-readable endpoint surface — every operation name, request schema, and response shape — see the live OpenAPI spec at https://api.robosystems.ai/docs (or http://localhost:8000/docs locally). This page covers the concepts and tasks; the OpenAPI spec is the authoritative endpoint reference.
The graph_id is always a URL path parameter, never a query argument or request-body field. The path accepts both parent graph IDs (kg + 16 or more hex chars, e.g. kg1a2b3c4d5e6f7890) and subgraph IDs ({parent_id}_{subgraph_name}, e.g. kg1a2b3c4d5e6f7890_dev).
This matters most for GraphQL. The graph is determined by the URL, and resolvers read it from request context — so GraphQL queries do not take a graphId argument:
Wrong: { entity(graphId: "kg1a2b3c4d5e6f7890") { name } }
Right: { entity { name } }
Putting the tenant in the URL rather than the query body eliminates a whole class of "I queried the wrong tenant" bugs: the scope is fixed before the resolver runs, and there is no way to override it from inside the query.
Authentication and per-graph access are enforced by FastAPI dependencies that run before any resolver, command runner, or view handler executes. A request that lacks access to the graph never reaches business logic.
- GraphQL requests resolve the user and graph through the context dependency, then check graph access before the query is executed.
- Command and view operations resolve the user and validate per-graph access via the operation dependency before the runner is invoked.
For local and backend testing, authenticate with the X-API-Key header. The key (format rfs followed by 64 hex characters) is written to .local/config.json after you run just demo-user. Read it inline with jq rather than stashing it in a shell variable:
-H "X-API-Key: $(jq -r .api_key .local/config.json)"Authorization: Bearer (JWT) is a frontend concern — the apps use an HTTP-only cookie. Do not use Bearer tokens for backend curl testing.
Each sub-surface is gated by a feature flag, and the gating happens at schema-construction time, not request time. Disabled domains are simply absent from the API rather than mounted and throwing runtime errors.
| Flag | Gates | Default |
|---|---|---|
ROBOLEDGER_ENABLED |
RoboLedger command operations + reads + GraphQL ledger fields | true |
ROBOINVESTOR_ENABLED |
RoboInvestor command operations + GraphQL investor fields | true |
EXTENSIONS_GRAPHQL_ENABLED |
Kill switch for the /extensions/{graph_id}/graphql endpoint |
true |
FACT_GRID_ENABLED |
Graph-backed analytical view operations (build-fact-grid, etc.) |
true |
EXTENSIONS_ENABLED is a derived property — it is ROBOLEDGER_ENABLED OR ROBOINVESTOR_ENABLED, not a standalone environment variable. It controls whether the extensions database engine is active at all. (The legacy LEDGER_ENABLED / INVESTOR_ENABLED names have been retired; use the ROBO* names.)
The GraphQL schema is composed per deployment. A baseline set of query fields is always present (information blocks, taxonomy blocks, and the library), and the domain-specific field sets are added only when their flag is on:
-
ROBOLEDGER_ENABLEDadds the ledger query fields (roughly three dozen —entity,trialBalance,fiscalCalendar,reports,mappings, and more). -
ROBOINVESTOR_ENABLEDadds the investor query fields (portfolios,securities,positions,holdings, and related).
Because the schema is built at construction time, a ledger-only deployment literally has no portfolios field — introspection never reveals it, and clients should branch on the introspected schema rather than discovering disabled domains through trial-and-error runtime errors. Note that analytical view operations are gated by FACT_GRID_ENABLED independently of ROBOLEDGER_ENABLED: they are mounted under the /extensions/roboledger/... path but stay available on deployments (such as SEC-only setups) that have no RoboLedger tenants.
Both command writes and analytical view operations return an OperationEnvelope. This is the same envelope/idempotency/SSE infrastructure used by graph lifecycle writes under /v1/graphs/{graph_id}/operations/{op_name}. On the wire, fields use camelCase aliases:
| Field | Meaning |
|---|---|
operation |
The kebab-case operation name (e.g. close-period) |
operationId |
A ULID prefixed with op_; the bridge to SSE progress streaming |
status |
"completed" (HTTP 200, ran synchronously), "pending" (HTTP 202, async), or "failed"
|
result |
The operation's response payload, or null
|
at |
ISO-8601 UTC timestamp |
createdBy |
The user ID that issued the operation |
idempotentReplay |
true when the response was served from the idempotency cache without re-executing |
Two behaviors fall out of this contract:
-
Idempotency. Every operation accepts an
Idempotency-Keyheader for safe retries. Replaying an identical request returns the cached result withidempotentReplay: trueand does not re-execute. Reusing the same key with a different request body returns HTTP 409 (a fingerprint conflict). -
Async operations stream progress. Long-running commands return
status: "pending"with HTTP 202; monitor them via Server-Sent Events at/v1/operations/{operationId}/streamrather than expecting the result inline.
All three sub-surfaces — plus the MCP tool layer — delegate to the same domain functions in operations/{domain}/{reads,commands,views}. Those functions are the only place domain logic lives:
-
GraphQL resolvers delegate reads to
operations/{domain}/reads/*. -
Command operation routers delegate writes to
operations/{domain}/commands/*. -
Analytical view handlers delegate to
operations/{domain}/views/*. - MCP tools (the operational and analytical retrieval planes) call the same ops-layer modules directly.
This invariant is load-bearing: whether a request arrives through GraphQL, a named operation, or an MCP tool, the same function runs and the same business rules are enforced. Adding logic anywhere else — in a router, a resolver, or an MCP handler — is a mistake.
All examples assume a local stack (just start) and a graph provisioned via just demo-roboledger (which writes the API key to .local/config.json). Set GRAPH_ID to the graph you want to target.
The hello field is an auth probe that returns no business data — it works on any extensions-enabled graph and is the quickest way to confirm credentials and reachability:
curl -X POST "http://localhost:8000/extensions/$GRAPH_ID/graphql" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"query": "{ hello }"}'The graph is in the URL, so the query carries no graphId argument. Field names are camelCase on the wire:
curl -X POST "http://localhost:8000/extensions/$GRAPH_ID/graphql" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"query": "{ fiscalCalendar { closedThrough closeTarget } entity { name } }"}'GraphQL list resolvers take limit (per-resolver default, must be 1–1000) and offset (default 0, must be ≥ 0). A success returns {"data": {...}}; a typed error returns {"errors": [{"extensions": {"code": "..."}}]}. Clients should branch on extensions.code (for example UNAUTHENTICATED, FORBIDDEN, INVALID_PAGINATION, LEDGER_NOT_INITIALIZED, INVESTOR_NOT_INITIALIZED), not on the human-readable message.
A RoboLedger command write returns an OperationEnvelope and supports Idempotency-Key:
curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/close-period" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Idempotency-Key: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"period": "2026-03", "allow_stale_sync": false}'A graph-backed analytical view that pivots over the XBRL hypercube. It hits the materialized LadybugDB graph rather than the OLTP database:
curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/build-fact-grid" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{
"canonical_concepts": ["revenue"],
"entities": ["NVDA", "AMD"],
"form": "10-K",
"fiscal_year": 2024,
"fiscal_period": "FY",
"view_config": {"rows": [{"type": "entity"}]}
}'Important: build-fact-grid requires a scoping filter. You must supply elements and/or canonical_concepts, and at least one of periods / period_type / fiscal_year. Omitting both classes of filter returns HTTP 400.
RoboInvestor writes go through the same operation surface under the roboinvestor path segment, and require a graph with ROBOINVESTOR_ENABLED:
curl -X POST "http://localhost:8000/extensions/roboinvestor/$GRAPH_ID/operations/create-security" \
-H "X-API-Key: $(jq -r .api_key .local/config.json)" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Common Stock", "security_type": "common_stock"}'For the exact request-body fields of any operation, consult the OpenAPI spec at http://localhost:8000/docs rather than guessing.
-
GraphQL takes no
graphIdargument. Use{ entity { … } }, never{ entity(graphId: "kg_x") }. The graph is the URL. -
Use
X-API-Keyfor local and backend testing, notAuthorization: Bearer. Read the key from.local/config.jsonwithjq -r .api_key .local/config.json. Do not put the key in aTOKEN=shell variable or in the URL. -
Analytical views are gated by
FACT_GRID_ENABLED, notROBOLEDGER_ENABLED. They live under the/extensions/roboledger/...path but are flagged independently, so they remain available on SEC-only deployments. -
build-fact-gridrequires a scoping filter —elementsand/orcanonical_concepts, plus at least one ofperiods/period_type/fiscal_year, or you get HTTP 400. -
A GraphQL "error" can still return HTTP 200. Anonymous introspection is allowed, so an unauthenticated request can return a
200with anUNAUTHENTICATEDerror code inextensions.code; HTTP 401 means credentials were presented but invalid. -
Idempotency conflicts return HTTP 409 when the same key is reused with a different request body. Identical replays return
idempotentReplay: truewithout re-executing. -
Async commands return HTTP 202 with
status: "pending". Monitor them over SSE at/v1/operations/{operationId}/stream; do not expect the result inline. - Disabled-domain fields are simply absent. Branch on the introspected schema, not on trial-and-error runtime errors.
-
Pagination bounds are enforced.
limitmust be 1–1000 or the resolver returnsINVALID_PAGINATION.
Wiki Guides:
- Architecture Overview - Platform architecture; its "Extensions API" subsection is the parent context for this page
- GraphQL Reads - Deep dive on the typed read surface and field catalog
- RoboLedger Operations - The RoboLedger command operation catalog
- RoboInvestor Operations - The RoboInvestor command operation catalog
- RoboLedger Demo Walkthrough - End-to-end close and reporting workflow against synthetic data
Codebase Documentation:
- GraphQL Extensions - Strawberry GraphQL surface, Pydantic auto-derivation, and resolver patterns
- Operations - The reads/commands/views domain kernel that all sub-surfaces delegate to
- Authentication - API key and JWT authentication
- API Models - Pydantic request/response models for the operation surface
- API Documentation - Live OpenAPI spec with the full endpoint surface
© 2026 RFS LLC
- Authentication & API Keys
- Graphs & Multi-Tenancy
- Shared Repositories
- Graph Operations
- Querying the Analytical Graph
- Credits & Billing
- AI Operators & MCP
- Pipeline Guide
- Extensions Surface Overview
- GraphQL Reads
- RoboLedger Operations
- RoboInvestor Operations
- Connecting QuickBooks Locally