-
Notifications
You must be signed in to change notification settings - Fork 7
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. Typed 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. There are two:
build-fact-grid(multi-dimensional pivot tables over the XBRL hypercube) andfinancial-statement-analysis. These are gated independently of the OLTP domains, so a deployment without RoboLedger tenants can still use them. -
One more shape rides the command-operation transport. A few RoboLedger operations are POST
…/operations/{name}and return anOperationEnvelopelike a command write, but read the OLTP database instead of writing to it.live-financial-statementgenerates an ad-hoc statement straight from the tenant's ledger using the active CoA→GAAP mapping, with no materialization step. Because it reads the same PostgreSQL database the ledger commands write, it is gated byROBOLEDGER_ENABLEDrather thanFACT_GRID_ENABLED.
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 pattern accepts parent graph IDs (kg + 16 or more hex chars, e.g. kg1a2b3c4d5e6f7890), shared-repository IDs (sec, sec_historical), the reserved library sentinel (which browses the shared public taxonomy library), 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.
Subgraph IDs are the one case where matching the pattern is not enough. On the GraphQL sub-surface a subgraph is rejected with HTTP 403 — a subgraph is a modality container with no extensions schema of its own, so the request is refused rather than quietly resolved to its parent. The check runs after authentication and before the access check, so the denial stays attributable. Target the parent graph instead. The command and view operations carry no such guard.
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, financial-statement-analysis) |
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 database that engine opens is configured with EXTENSIONS_DATABASE_URL — a database separate from the platform's DATABASE_URL, with its own independent migration history (both live on the same RDS instance in deployed environments). Tenancy inside it is schema-per-graph: each request sets search_path to the graph's own schema with public as the fallback, so a graph's rows are never visible from another graph's session.
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 the graph-backed 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. The OLTP-backed read operation live-financial-statement sits on the same path but follows ROBOLEDGER_ENABLED.
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. -
Graph-backed analytical views (
build-fact-grid,financial-statement-analysis) are gated byFACT_GRID_ENABLED, notROBOLEDGER_ENABLED. They live under the/extensions/roboledger/...path but are flagged independently, so they remain available on SEC-only deployments.live-financial-statement, which reads the OLTP database rather than the graph, followsROBOLEDGER_ENABLEDlike the command writes. - Subgraph IDs are rejected on the GraphQL endpoint with HTTP 403. They match the path pattern, so the failure surfaces at runtime rather than as a validation error. Target the parent graph. The operation endpoints do not apply this restriction.
-
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
- Quick Start
- Core Concepts
- Architecture Overview
- Bootstrap Guide
- Windows Setup (WSL2)
- Security & Compliance
- Authentication & API Keys
- Enterprise SSO & SCIM
- 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