API Overview
How ObjectStack generates its API surface from metadata — discovery, error handling wire formats, protocol types, and a map of the API & SDK docs.
API Overview
ObjectStack generates its entire API surface — REST endpoints, realtime protocols, and the client SDK — from your metadata: define an object once and its endpoints exist. This module documents those surfaces and how to consume them.
ObjectStack exposes a fully typed REST API. All endpoints use JSON request/response bodies. The API is service-driven — routes are only available when the corresponding plugin is installed. Use the Discovery endpoint to determine what services are available at runtime.
Surfaces at a glance
| Surface | Status in this repo |
|---|---|
| REST | ✅ Auto-generated from the protocol (@objectstack/rest) — CRUD, query, batch, metadata, packages |
| Realtime | ⚠️ In-process pub/sub service (@objectstack/service-realtime, single-instance); the /realtime/* REST routes and WebSocket/SSE transport are plugin-provided — none ships in the open framework |
| MCP | ✅ Non-system objects exposed automatically as Model Context Protocol tools; actions additionally require the author's ai.exposed opt-in — every call is gated by the caller's permissions/RLS (AI module) |
| GraphQL | ⚠️ Route is wired but bring-your-own service: /graphql returns 501 unless an implementation of the IGraphQLService contract is registered — none ships in the open framework |
| OData | ⚠️ Vocabulary only: REST list endpoints accept OData-style operators (e.g. $top), but there is no standalone OData endpoint |
Base URL: Configurable, defaults to /api/v1. All paths below are relative to the base URL.
Your app as an MCP server
REST and GraphQL are how code consumes your app. MCP is how AI consumes it.
Because every object and action is typed metadata, ObjectStack can expose the whole app
as a Model Context Protocol server — so an AI client
(Claude Code, Claude Desktop, Cursor, a local model) can inspect and operate the app
you built, under the same permissions and RLS as the UI. It is served at /api/v1/mcp
by default — set OS_MCP_SERVER_ENABLED=false to opt out
(see environment variables).
The generated tools mirror the surfaces you already defined:
list_objects / describe_object (discover the schema), query_records / get_record
(read), create_record / update_record / delete_record (write), and
list_actions / run_action (invoke your business actions by name). Every call runs as
the caller — RBAC, RLS, and field-level security all apply.
sequenceDiagram
participant AI as AI client (Claude / Cursor)
participant MCP as MCP server (@objectstack/mcp)
participant Eng as Action / ObjectQL engine
participant Sec as RBAC · RLS · FLS
participant DB as Data
AI->>MCP: run_action("resolve", { recordId })
MCP->>Eng: dispatch as the caller's principal
Eng->>Sec: check permissions on this record
Sec-->>Eng: allowed (or denied — fail-closed)
Eng->>DB: apply the change
DB-->>AI: result, same as the Console would return
See Actions as Tools for the run_action bridge and the
MCP reference for binding external MCP servers into your agents.
Authentication
Every REST call runs as a principal — anonymous requests only see what your permission model grants anonymous users. Two ways to authenticate:
- Session cookie (browsers, quick local tests):
POST /api/v1/auth/sign-in/emailwith{ "email": "…", "password": "…" }sets the session cookie — reuse it withcurl -c cookies.txt/-b cookies.txt. On a fresh dev database the seeded admin isadmin@objectos.ai/admin123. - API key (scripts, CI, headless agents): mint one with
POST /api/v1/keys(the key is shown once), or from Setup → Connect an Agent in the Console. Send it asx-api-key: osk_…orAuthorization: Bearer osk_….
See Authentication for the full identity surface (OAuth flows, sessions, providers) and Plugin Endpoints for the auth route catalog.
What's in this module
- Data API — CRUD, batch operations, record cloning, and analytics queries
- Metadata & Package API — object schemas, metadata types, UI views, and package management
- Plugin Endpoints — auth, workflow, realtime, and other plugin-provided routes
- Client SDK — the official TypeScript client
- Environment Routing — routing requests across environments
- Data Flow — how a request travels through the stack
- Error Handling (Client) — handling API errors in client code
- Error Handling (Server) — raising and shaping errors on the server
- Error Catalog — the full list of error codes
- Wire Format — request/response envelopes on the wire
Spec: HTTP API, Real-Time Protocols
Schema reference: API
Discovery
The discovery endpoint is the entry point for all clients. It returns the API version, available routes, service capabilities, and per-service status.
GET /api/v1 (and GET /api/v1/discovery)
Returns the full discovery manifest. @objectstack/rest registers one handler at both
paths — the API base path and <basePath>/discovery — so the two are the same document,
not a redirect and not two shapes. In a REST-less composition the runtime dispatcher
registers <basePath>/discovery as the fallback owner instead, and then serves its own
/.well-known/objectstack payload there (see below); when @objectstack/rest is mounted
the dispatcher cedes the route to it, so a single owner answers it (ADR-0076 D11).
Response:
{
"version": "v1",
"apiName": "ObjectStack API",
"routes": {
"data": "/api/v1/data",
"metadata": "/api/v1/meta"
},
"services": {
"metadata": {
"enabled": true,
"status": "available",
"route": "/api/v1/meta",
"provider": "objectql"
},
"data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" },
"analytics": { "enabled": false, "status": "unavailable", "message": "Install service-analytics to enable" },
"auth": { "enabled": false, "status": "unavailable", "message": "Install plugin-auth to enable" }
},
"capabilities": {
"cron": { "enabled": false },
"automation": { "enabled": false },
"search": { "enabled": false },
"transactionalBatch": { "enabled": true, "description": "Atomic cross-object batch endpoint (POST {basePath}/batch)…" }
}
}Disabled/uninstalled route keys (e.g. auth, analytics, workflow) are omitted from routes entirely rather than set to null; check services to tell "not installed" apart from "installed but not yet mounted here." The sample above shows a minimal install: analytics reports unavailable and advertises no route until @objectstack/service-analytics registers the engine — the dispatcher then also mounts /api/v1/analytics/* (the routes are capability-conditional; an uninstalled capability answers 404 for every method).
metadata is reported from whatever implementation fills its slot, so the sample's available is the MetadataPlugin case (a persisted sys_metadata registry). A stack running the kernel's in-memory fallback instead reports status: "degraded" with a message naming what is missing and what to install. handlerReady is true either way: /api/v1/meta is served by the protocol, so the route is mounted whichever registry sits behind it.
capabilities is a flat map of platform feature flags, one entry per well-known
capability (comments, automation, cron, search, export, chunkedUpload,
transactionalBatch), each derived from what is actually registered — never hardcoded.
transactionalBatch (#3298, ADR-0034) is the one worth negotiating at connect time: it is
true iff the atomic cross-object batch route (POST {basePath}/batch) is mounted
and the runtime engine can honour a transaction, so a client can decide once whether to
send an atomic batch or fall back to client-side sequencing, instead of probing for
404/405/501. See Data API → batch.
GET /.well-known/objectstack
Served by the runtime dispatcher (@objectstack/runtime), not @objectstack/rest — its body is wrapped as { "data": { ... } } and includes fields (name, environment, features, locale) that the @objectstack/rest-served /api/v1 response above does not. The client SDK's connect() tries /api/v1/discovery first and falls back to this endpoint, unwrapping either body.data or the bare body.
Service Status Values: available (fully operational), registered (route declared but handler unverified — may return 501), degraded (partial functionality), unavailable (not installed), stub (placeholder that throws errors)
What an absent capability answers
Discovery never advertises a route for a service it reports unavailable. If you call one anyway, the status tells you which kind of absence you hit:
| You get | Meaning | Example |
|---|---|---|
| 404 | The route is not mounted. The server does not expose this path at all. | /analytics/* without an analytics service — the mount itself is gated; /mcp when the MCP server is disabled for the environment |
| 501 | The route is mounted; nothing implements it. The request reached a handler that had nothing to delegate to. | /automation, /notifications, /ui/*, /ai/*, /auth/*, /i18n/*, /graphql without their backing service |
A 501 body names the package that would provide the capability — the same sentence services.<slot>.message carries in discovery, so the wall and the discovery entry always agree. A 404 here means what 404 always means: check the path.
Neither is retryable. Nothing answers 503 for a missing capability; that status is reserved for genuinely transient states (the kernel still booting, on GET /ready).
Error Handling
Error responses depend on which HTTP server is in front of the kernel. There are two wire formats in use today.
Kernel REST server (@objectstack/rest) emits a string error message plus a SCREAMING_SNAKE code:
{
"error": "Record not found: account/123",
"code": "RECORD_NOT_FOUND"
}Validation failures additionally include a fields array (one entry per invalid field). Common codes emitted by the kernel REST server:
| Code | HTTP | Description |
|---|---|---|
VALIDATION_FAILED | 400 | Input validation failed (includes fields) |
PERMISSION_DENIED | 403 | Insufficient permissions |
RECORD_NOT_FOUND | 404 | Resource does not exist |
CONCURRENT_UPDATE | 409 | Record was modified by another user |
Runtime dispatcher (@objectstack/runtime) wraps errors in the { success: false, ... } envelope declared by ApiErrorSchema. code is the semantic string; the numeric HTTP status is on httpStatus, and details carries structured context only:
{
"success": false,
"error": {
"code": "RECORD_NOT_FOUND",
"message": "Record not found: account/123",
"httpStatus": 404
}
}Branch on error.code, never on error.httpStatus — the status answers "what
class of failure" and the code answers "which failure". A branch that has no
code of its own is served a StandardErrorCode derived from the status
(403 → PERMISSION_DENIED, 503 → SERVICE_UNAVAILABLE, …).
The richer ErrorResponseSchema in @objectstack/spec/api (with category, retryable, etc.) is the aspirational spec envelope, not the current wire format. Its category values are drawn from the ErrorCategory enum: validation, authentication, authorization, not_found, conflict, rate_limit, server, external, maintenance.
Protocol Types (Zod)
All request/response schemas are defined as Zod schemas in @objectstack/spec/api and can be used for both runtime validation and TypeScript type inference.
import {
FindDataRequestSchema,
FindDataResponseSchema,
type FindDataRequest,
type FindDataResponse,
} from '@objectstack/spec/api';
// Runtime validation
const request = FindDataRequestSchema.parse({ object: 'account', query: { ... } });
// TypeScript type
const response: FindDataResponse = await protocol.findData(request);See the Protocol Reference for the full list of protocol methods and their Zod schemas.