| title | Client SDK |
|---|---|
| description | The official TypeScript client for ObjectStack — auto-discovery, typed metadata, CRUD, batch operations, and service-aware feature detection. |
The @objectstack/client is the official TypeScript client for ObjectStack. It provides a typed, protocol-aware interface that automatically adapts to your server's available services.
- Auto-Discovery: Connects to your ObjectStack server and discovers available services, routes, and capabilities
- Service-Aware: Checks
discovery.servicesto determine which features are available before calling them - Typed Metadata: Retrieve Object and View definitions with full type support
- Metadata Caching: ETag-based conditional requests for efficient metadata caching
- Unified Data Access: Simple CRUD operations for any object in your schema
- Batch Operations: Efficient bulk create/update/upsert/delete with transaction support
- Query Builder: Programmatic query construction with
createQuery()andcreateFilter() - Standardized Errors: Machine-readable error codes with retry guidance
- Protocol Compliant: Implements the core API namespaces defined in
@objectstack/spec, plus additional namespaces (approvals, organizations, projects/environments)
pnpm add @objectstack/clientimport { ObjectStackClient } from '@objectstack/client';
const client = new ObjectStackClient({
baseUrl: 'http://localhost:3004',
});
async function main() {
// 1. Connect — resolves with the discovery manifest
const discovery = await client.connect();
// 2. Check available services
console.log('Services:', discovery.services);
// → { metadata: { enabled: true, status: 'degraded' }, data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } }
// 3. Query data
const tasks = await client.data.find('todo_task', {
fields: ['subject', 'priority'],
where: { priority: { $gte: 2 } },
orderBy: ['-priority'],
limit: 10
});
// 4. Create a record
const newTask = await client.data.create('todo_task', {
subject: 'New Task',
priority: 1
});
// 5. Batch operations
const result = await client.data.batch('todo_task', {
operation: 'update',
records: [
{ id: '1', data: { status: 'active' } },
{ id: '2', data: { status: 'active' } }
],
options: { atomic: true, returnRecords: true }
});
console.log(`Updated ${result.succeeded} records`);
}When you call client.connect(), the client:
- Probes
/api/v1/discoveryfirst, then falls back to{origin}/.well-known/objectstack - Parses the discovery response including
routes,capabilities, andservices - Configures all API route paths dynamically
// connect() resolves with the discovery manifest — capture the return value
const discovery = await client.connect();
console.log(discovery.version); // "1.0.0"
console.log(discovery.environment); // "development"
// Check if a service is available before using it
if (discovery.services?.auth?.enabled) {
// Auth plugin is installed — login is available
await client.auth.login({ email: 'admin@example.com', password: 'secret' });
} else {
console.log(discovery.services?.auth?.message);
// → "Install an auth plugin to enable"
}The @objectstack/client SDK aims to implement the ObjectStack API protocol specification. The core namespaces are listed below; the client also exposes additional namespaces (approvals, organizations, oauth, projects/environments) — see index.ts for the full surface:
| Namespace | Status | Methods | Purpose |
|---|---|---|---|
| discovery | ✅ | 1 | API version & capabilities detection |
| meta | ✅ | 18 | Metadata read/write, published versions & drafts (ADR-0033), per-item publish/rollback/diff, FSM introspection (ADR-0020), diagnostics, references, audit trail, book trees (ADR-0046) |
| data | ✅ | 12 | CRUD & query operations, record clone, streaming export (M10.9) |
| auth | ✅ | 5 | Authentication & user management |
| packages | ✅ | 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
| analytics | ✅ | 4 | Analytics queries + semantic-layer dataset query (ADR-0021) |
| automation | ✅ | 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
| actions | ✅ | 2 | Server-registered action handlers (engine.registerAction) |
| approvals | ✅ | 12 | Approval requests (ADR-0019): inbox, decisions, recall, revise/resubmit (ADR-0044), thread interactions, audit trail |
| reports | ✅ | 8 | Saved reports: definitions, execution, recurring email schedules (501 without @objectstack/plugin-reports) |
| shares | ✅ | 8 | Per-record sharing grants (list/grant/revoke) + tenant-wide sharing rules (shares.rules.*, M10.17) |
| search | ✅ | 1 | Global cross-object search (M10.5) |
| ✅ | 1 | Transactional send via IEmailService (M11.B1) | |
| datasources | ✅ | 5 | External-datasource federation admin: browse/draft/import remote tables (ADR-0015) |
| keys | ✅ | 1 | API key minting (one-time secret) |
| shareLinks | ✅ | 3 | Record share-link management |
| security | ✅ | 4 | Access explanation (ADR-0090 D6) + suggested audience bindings (admin) |
| storage | ✅ | 2 | File upload & download |
| i18n | ✅ | 3 | Internationalization |
| notifications | ✅ | 3 | List, mark-read, mark-all-read (inbox/receipt spine, ADR-0030) |
| ai | ✅ | 3 | AI services (NLQ, suggest, insights) |
The former permissions, views, workflow, and realtime namespaces (and
the notifications device/preference helpers) were removed in #3612: no server
surface ever mounted their routes, so every call was a guaranteed 404.
// Get object schema (getItem('object', name) returns the object definition)
const accountSchema = await client.meta.getItem('object', 'account');
console.log(accountSchema.fields);
// List all metadata types
const types = await client.meta.getTypes();
// → { types: ['object', 'view', 'plugin', ...] }
// Get with ETag caching
const cached = await client.meta.getCached('account', {
ifNoneMatch: '"686897696a7c876b7e"'
});
if (cached.notModified) {
console.log('Using cached metadata');
}
// Get auto-generated view
const listView = await client.meta.getView('account', 'list');
// Compound names (sub-resources) pass through unencoded
const view = await client.meta.getItem('object', 'views/all_leads');
// Per-item draft lifecycle (ADR-0033)
await client.meta.publishItem('object', 'account', { message: 'go live' });
await client.meta.rollbackItem('object', 'account', 3);
const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 });
// Introspection & governance
const bad = await client.meta.getDiagnostics({ severity: 'error' });
const refs = await client.meta.getReferences('object', 'account');
const trail = await client.meta.getAudit('object', 'account', { limit: 20 });
const tree = await client.meta.getBookTree('handbook');// Find with query options
const accounts = await client.data.find('account', {
fields: ['name', 'industry', 'revenue'],
where: { industry: 'Technology' },
orderBy: ['-revenue'],
limit: 20,
offset: 0,
});
// Get by ID
const account = await client.data.get('account', '123');
// Create
const created = await client.data.create('account', {
name: 'Acme Corp',
industry: 'Technology',
});
// Update (partial)
const updated = await client.data.update('account', '123', {
industry: 'Healthcare',
});
// Delete
await client.data.delete('account', '123');
// Batch operation (recommended for bulk)
const batchResult = await client.data.batch('account', {
operation: 'upsert',
records: [
{ id: '1', data: { name: 'Acme', status: 'active' } },
{ id: '2', data: { name: 'Beta', status: 'active' } },
],
options: {
atomic: true,
returnRecords: true,
continueOnError: false,
validateOnly: false,
},
});
// Convenience bulk methods
await client.data.createMany('account', [{ name: 'A' }, { name: 'B' }]);
await client.data.updateMany('account', [
{ id: '1', data: { status: 'closed' } },
{ id: '2', data: { status: 'closed' } },
]);
await client.data.deleteMany('account', ['1', '2', '3']);
// Atomic cross-object batch (master-detail save) — runs every operation in
// ONE server-side transaction (POST /api/v1/batch): commit all or roll back
// all. `{ $ref: <op index> }` lets a child reference a parent created earlier
// in the same request. Always atomic — unlike per-object `data.batch()`,
// there is no partial-success mode.
const { results } = await client.data.batchTransaction([
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
{ object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
]);See Batch Operations for the underlying endpoint contract.
// Semantic analytics query (cube-style)
const result = await client.analytics.query({
cube: 'account',
measures: ['revenue.sum', 'count'],
dimensions: ['industry'],
where: { status: 'active' },
limit: 100,
});
// Get cube metadata — all cubes, or one with meta('account')
const meta = await client.analytics.meta('account');
// Dry-run a query to its generated SQL (POST /analytics/sql)
const explained = await client.analytics.explain({
cube: 'account',
measures: ['revenue.sum'],
});const packages = await client.packages.list();
await client.packages.install({ id: 'com.objectstack.plugin-auth', version: '1.0.0' });
await client.packages.enable('com.objectstack.plugin-auth');
await client.packages.disable('com.objectstack.plugin-auth');
await client.packages.uninstall('com.objectstack.plugin-auth');The client also provides full implementations for:
// Auth — User authentication and session management
await client.auth.login({ email: 'user@example.com', password: 'pass' });
await client.auth.register({ email: 'new@example.com', password: 'pass', name: 'New User' });
await client.auth.me();
await client.auth.logout();
await client.auth.refreshToken('refresh-token-string');
// Approvals — request-based decision API (ADR-0019)
// Approval is a flow node, not a workflow step: decisions are keyed by request id.
await client.approvals.listRequests({ status: 'pending' }); // "my approvals" inbox
await client.approvals.getRequest(requestId);
await client.approvals.approve(requestId, { comment: 'Looks good' });
await client.approvals.reject(requestId, { comment: 'Incomplete' });
await client.approvals.listActions(requestId); // audit trail
// Notifications — inbox/receipt spine (ADR-0030)
await client.notifications.list({ read: false });
await client.notifications.markRead(['notif-1', 'notif-2']);
await client.notifications.markAllRead();
// ADR-0030 note: the framework pipeline now materializes in-app messages
// through sys_inbox_message and tracks read-state in sys_notification_receipt.
// These helpers will be repointed during the objectui bell cut-over.
// AI — AI-powered features
await client.ai.nlq({ query: 'Show me all active accounts' });
await client.ai.suggest({ object: 'account', field: 'industry' });
await client.ai.insights({ object: 'sales', recordId: dealId });
// Note: conversational chat is no longer on the client — use the Vercel AI SDK
// (`useChat()` from `@ai-sdk/react`) directly against your chat endpoint.
// i18n — Internationalization
await client.i18n.getLocales();
await client.i18n.getTranslations('zh-CN');
await client.i18n.getFieldLabels('account', 'zh-CN');
// Automation — Trigger workflows and automations
await client.automation.trigger('send_welcome_email', { userId });
// Screen flows pause for user input instead of completing. `execute()` returns
// `{ status: 'paused', runId, screen }`; render the screen, then resume the run
// with the collected values. A wizard pauses again for each further step.
const run = await client.automation.execute('convert_lead', { params: { recordId } });
if (run.status === 'paused') {
await client.automation.resume('convert_lead', run.runId, {
inputs: { account_name: 'Radium Labs' },
});
}
// Re-fetch the pending screen when the client did not launch the run itself
// (a page reload, another tab, an inbox):
await client.automation.getScreen('convert_lead', runId);
// Actions — Invoke server-registered action handlers
// (engine.registerAction). The result envelope is the HANDLER's own
// `{ success, data | error }` — a business failure comes back as
// `success: false` + `error` instead of a thrown exception, so it can be
// surfaced as a toast.
const res = await client.actions.invoke('crm_lead', 'convert', {
recordId,
params: { create_opportunity: true },
});
if (!res.success) console.warn(res.error);
// Global (object-less) actions dispatch to the wildcard handler:
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
// API Keys — the raw secret is returned exactly ONCE; store it immediately.
const apiKey = await client.keys.create({ name: 'CI key', expiresAt: '2027-01-01' });
console.log(apiKey.key); // never re-displayable
// Share Links — manage record share links (public token URLs stay browser-only)
const link = await client.shareLinks.create('crm_account', recordId, {
permission: 'view',
expiresAt: '2026-12-31',
});
await client.shareLinks.list({ object: 'crm_account' });
await client.shareLinks.revoke(link.token);
// Security (admin) — resolve package audience-binding suggestions (ADR-0090)
const { suggestions } = await client.security.suggestedBindings.list({ status: 'pending' });
await client.security.suggestedBindings.confirm(suggestions[0].id);
// Storage — File upload and management
await client.storage.upload(fileData, 'user');
await client.storage.getDownloadUrl('file-123');Build complex queries programmatically:
import { createQuery, createFilter } from '@objectstack/client';
const query = createQuery('account')
.select('name', 'industry', 'revenue')
.where((f) => {
f.equals('industry', 'Technology');
f.greaterThan('revenue', 10000);
})
.orderBy('revenue', 'desc')
.limit(50)
.build();
const results = await client.data.query('account', query);The FilterBuilder provides a rich set of filter methods:
import { createFilter } from '@objectstack/client';
const filter = createFilter()
.equals('status', 'active') // field = value
.notEquals('type', 'archived') // field != value
.greaterThan('revenue', 10000) // field > value
.lessThanOrEqual('age', 100) // field <= value
.in('category', ['A', 'B', 'C']) // field IN (...)
.like('name', '%Corp%') // field LIKE pattern
.contains('name', 'Corp') // LIKE %Corp%
.startsWith('name', 'Acme') // LIKE Acme%
.isNull('deleted_at') // field IS NULL
.between('created_at', '2024-01', '2024-12')
.build();The find method accepts an options object with canonical (recommended) field names:
| Property | Type | Description | Example |
|---|---|---|---|
where |
Object or FilterCondition |
Filter conditions (WHERE clause) | { status: 'active' } |
fields |
string[] |
Fields to retrieve (SELECT) | ['name', 'email'] |
orderBy |
string or string[] or SortNode[] |
Sort order (ORDER BY) | ['-created_at'] |
limit |
number |
Max records to return (LIMIT) | 20 |
offset |
number |
Records to skip (OFFSET) | 0 |
expand |
Record<string, any> or string[] |
Relation loading (JOIN) | { owner: {} } |
| Property | Type | Default | Description |
|---|---|---|---|
atomic |
boolean |
true |
Rollback entire batch on any failure |
returnRecords |
boolean |
false |
Include full records in response |
continueOnError |
boolean |
false |
Continue after errors (when atomic=false) |
validateOnly |
boolean |
false |
Dry-run mode — validate without persisting |
All errors follow a standardized format:
try {
await client.data.create('todo_task', { subject: '' });
} catch (error) {
console.error(error.code); // 'validation_error'
console.error(error.category); // 'validation'
console.error(error.httpStatus); // 400
console.error(error.retryable); // false
console.error(error.details); // { ... }
}| Code | HTTP | Category | Retryable | Description |
|---|---|---|---|---|
validation_error |
400 | validation | No | Input validation failed |
invalid_query |
400 | validation | No | Malformed query expression |
unauthenticated |
401 | authentication | No | Authentication required |
permission_denied |
403 | authorization | No | Insufficient permissions |
resource_not_found |
404 | not_found | No | Resource does not exist |
rate_limit_exceeded |
429 | rate_limit | Yes | Too many requests |
internal_error |
500 | server | Yes | Unexpected server error |
service_unavailable |
503 | server | Yes | Service temporarily unavailable |
not_implemented |
501 | server | No | Service not installed (plugin missing) |
interface ClientConfig {
/** ObjectStack server URL */
baseUrl: string;
/** Bearer token for authentication (if auth plugin installed) */
token?: string;
/** Custom fetch implementation (e.g. for SSR or testing) */
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/** Logger instance for debugging */
logger?: Logger;
/** Enable debug logging */
debug?: boolean;
/** Active project/environment id — injects an `X-Environment-Id` header for multi-tenant routing */
environmentId?: string;
/** Active UI locale (BCP-47, e.g. `'zh-CN'`) — injects an `Accept-Language` header; sync via `setLocale()` */
locale?: string;
}For React applications, use @objectstack/client-react:
pnpm add @objectstack/client-reactimport { ObjectStackProvider, useClient, useQuery } from '@objectstack/client-react';
import { ObjectStackClient } from '@objectstack/client';
// 1. Wrap your app with the provider
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3004' });
function App() {
return (
<ObjectStackProvider client={client}>
<AccountList />
</ObjectStackProvider>
);
}
// 2. Use hooks in child components
function AccountList() {
const { data, isLoading, error, refetch } = useQuery('account', {
where: { status: 'active' },
orderBy: ['-created_at'],
limit: 20,
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return data?.records.map(a => <div key={a.id}>{a.name}</div>);
}| Hook | Purpose |
|---|---|
useClient() |
Access the ObjectStackClient instance from context |
useQuery(object, options) |
Query data with auto caching and refetching |
useMutation(object) |
Create/update/delete mutations |
usePagination(object, options) |
Paginated data queries |
useInfiniteQuery(object, options) |
Infinite scroll queries |
useObject(name) |
Get object metadata |
useView(object, type) |
Get view definition |
useFields(object) |
Get field definitions |
useMetadata(type, name) |
Get arbitrary metadata |
The client SDK includes comprehensive unit and integration tests to ensure reliability and protocol compliance.
cd packages/client
pnpm testUnit tests use mocks to verify client behavior without requiring a server.
Note: Integration tests require a running ObjectStack server. The server is provided by a separate repository and must be set up independently.
# Prerequisite: Start an ObjectStack server with test data
# For example, using the reference server repository
# Follow the server repository's documentation for local setup
# From this repository, run the integration test script
cd packages/client
pnpm test:integrationIntegration tests verify end-to-end communication with a live ObjectStack server across the client's API namespaces.
**Test coverage**: Integration test specifications cover discovery/connection, authentication, metadata operations, CRUD operations (basic, batch, advanced queries), permissions, workflow, realtime, notifications, AI services, i18n, analytics, packages, views, storage, and automation.For detailed information about the client's protocol implementation:
- Protocol Compliance Matrix — Method-by-method verification of all API methods across 13 namespaces
- Integration Test Specifications — Comprehensive test cases for client-server communication
- Package README — Developer navigation and API reference