Client SDK
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.
Features
- 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)
Installation
pnpm add @objectstack/clientQuick Start
import { 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', message: 'In-memory metadata registry — …' },
// data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } }
// `metadata` reports the implementation actually behind it: `degraded` plus a
// message naming what is missing while the kernel's in-memory fallback fills
// the slot, `available` once MetadataPlugin provides a persisted registry.
// 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`);
}Auto-Discovery
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); // the serving artifact's version
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"
}Plugin-driven services: Auth, workflow, automation, AI, and other services are only available when the corresponding plugin is installed. Always check discovery.services before calling plugin-provided API methods.
Protocol Coverage
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 | ✅ | 10 | Chat (JSON + streaming), completion, model picker, conversation CRUD — the surface service-ai (Cloud/EE) mounts |
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.
Coverage is CI-enforced, not hand-asserted: the #3563 route ledger
(route-ledger.ts)
records the audited disposition of every server route, and conformance tests
on both sides fail when a route lands without a reviewed disposition or the
ledger names a client method that doesn't exist. The client-side integration
tests that exist today are listed in
packages/client/tests/integration/README.md.
API Namespaces
client.meta — Metadata
// 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');
// Sub-resource identity is DOT-qualified — `<object>.<viewKey>`, never a `/`
const view = await client.meta.getItem('view', 'crm_lead.pipeline');
// 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');
// Operator: rewrite stored rows into today's canonical shape (ADR-0087).
// Preview unless `apply: true`; requires the `manage_metadata` capability.
const report = await client.meta.migrateStored({ apply: true });client.data — CRUD & Batch
// 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,
},
});
// 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.
client.analytics — BI Queries
// 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'],
});client.packages — Package Management
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');Additional Namespaces
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 — served by `service-ai` (Cloud/EE). Without it the route is mounted but
// unimplemented, so it answers 501 and discovery reports the slot unavailable
// with the same sentence; check `discovery.services` first.
const answer = await client.ai.chat({
messages: [{ role: 'user', content: 'How many open orders this quarter?' }],
conversationId, // omit to have one created and echoed back
});
answer.content; // string
answer.usage?.totalTokens;
// Streaming — the Vercel UI Message Stream Protocol, frame by frame.
for await (const frame of await client.ai.chatStream({ messages })) {
if (frame.type === 'text-delta') process.stdout.write(frame.delta as string);
}
await client.ai.complete({ prompt: 'Summarise this account in one line:' });
await client.ai.models(); // plan-filtered picker list (ADR-0028)
// Conversations — all six routes, scoped to the authenticated user server-side.
const conv = await client.ai.conversations.create({ title: 'Q3 pipeline' });
await client.ai.conversations.list({ limit: 20 });
await client.ai.conversations.get(conv.id);
await client.ai.conversations.addMessage(conv.id, { role: 'user', content: 'hi' });
await client.ai.conversations.update(conv.id, { title: 'Renamed' });
await client.ai.conversations.delete(conv.id);
// Named agents — `ai.chat` talks to the environment default; these talk to one
// you name. The catalog is access-filtered server-side (ADR-0049), so an empty
// list is a legitimate answer for a seat-less user, not an error to retry.
const agents = await client.ai.agents.list();
await client.ai.agents.chat('build', { messages, context: { appId: 'crm' } });
for await (const frame of await client.ai.agents.chatStream('build', { messages })) {
if (frame.type === 'text-delta') process.stdout.write(frame.delta as string);
}
// Pending actions — the human-in-the-loop queue. A tool call needing a human
// decision parks here instead of executing.
await client.ai.pendingActions.list({ status: 'pending', limit: 20 });
await client.ai.pendingActions.get('pa_1');
const outcome = await client.ai.pendingActions.approve('pa_1');
// CHECK `outcome.status`: approve runs the tool, and a tool that fails comes
// back { status: 'failed', error } on HTTP 200 — the approval succeeded, the
// execution did not. Code that reads only `res.ok` calls that a success.
if (outcome.status === 'failed') console.error(outcome.error);
await client.ai.pendingActions.reject('pa_1', 'wrong account');
// Listing takes `ai:read`, deciding takes `ai:approve` — a caller that can see
// the queue may still be refused on approve. Handle the 403.
// In a React chat UI prefer `useChat()` (`@ai-sdk/react`) over `ai.chatStream`:
// it speaks the same protocol and owns message state. These methods are for
// everything that is not a component — server code, jobs, CLIs, tests.
//
// #3718 history: `client.ai` used to hold `nlq`, `suggest` and `insights`,
// building /api/v1/ai/{nlq,suggest,insights}. No server in any repo ever
// mounted those paths, so every call 404ed for the whole life of the
// namespace. They were typed and shipped, which is exactly why they looked
// usable. v17 removed them; the methods above are the surface that exists.
// 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 });
// A flow that does not run REJECTS — it does not resolve with an inner
// `{ success: false }`. Branch on the thrown error's `code`, not on the
// resolved value. The narrowed `catch` this needs under `strict` is its own
// block below: "Flow execution errors", under Error Handling.
await client.automation.execute('order_approval', { params: { orderId } });
// 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 } });
// `execute` returns `AutomationResult`, on which `runId` and `screen` are
// OPTIONAL — a run that COMPLETED carries neither — so narrow before resuming.
if (run.status === 'paused' && run.runId) {
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 is `{ success, data | error }` — a
// failure comes back as `success: false` + `error` instead of a thrown
// exception, so it can be surfaced as a toast. This surface is the one place
// a non-2xx does NOT throw: failures speak HTTP (400 rejection, 404 / 403 /
// 503 dispatch failures, 500 crash) and the SDK folds them into the result;
// on success, `data` is the handler's return value directly.
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 server's 'global' handler key:
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' });
// A suggestion ROW is deliberately open (`Record<string, unknown>`) — its column
// set belongs to the backing object, not the contract. Read the fields you know
// (`id`, `status`, `package_id`) and narrow them at the point of use.
await client.security.suggestedBindings.confirm(String(suggestions[0].id));
// Storage — File upload and management
await client.storage.upload(fileData, 'user');
await client.storage.getDownloadUrl('file-123');Service availability: Optional services (automation, ai, etc.) are only available when the corresponding plugin is installed on the server. Always check the services map on the discovery result returned by client.connect() (cache it yourself — the client has no discovery getter) to verify service availability before calling these methods.
Query Builder
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);Filter Builder Methods
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%') // pattern match — the wildcards are YOURS
.ilike('name', '%corp%') // same, ignoring ASCII case
.contains('name', 'Corp') // literal substring — no wildcards involved
.startsWith('name', 'Acme') // literal prefix
.isNull('deleted_at') // field IS NULL
.between('created_at', '2024-01', '2024-12')
.build();like / ilike take a pattern: % matches any sequence, _ matches
exactly one character, and a backslash escapes either. The pattern is matched
against the whole value, so like('name', 'Corp') is an exact comparison — for
a substring search use contains, whose argument is plain text and needs no
escaping. like / ilike are executed by the SQL backends; the others refuse
them with invalid_filter.
Query Options
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: {} } |
Canonical names recommended: find() accepts the canonical names above; the legacy aliases (select, filter/filters, sort, top, skip) still work for backward compatibility, but new code should use the canonical names. (@objectstack/client-react's useQuery/useInfiniteQuery hooks are stricter — the legacy aliases were removed there in v11; see docs/upgrading-to-11.md.)
Batch Options
| Property | Type | Default | Description |
|---|---|---|---|
atomic | boolean | false | Run the batch in one transaction and roll every write back on the first failure. Refused with 501 NOT_IMPLEMENTED where the driver cannot roll back, rather than degrading to best-effort. Takes precedence over continueOnError |
returnRecords | boolean | false | Include full records in response |
continueOnError | boolean | false | Continue after errors (when atomic is false) |
A rolled-back atomic batch reports succeeded: 0, with each row's
errors[0].code set to ROLLED_BACK (written, then undone), the causal row's
own error code, or NOT_ATTEMPTED (never reached) — no row is reported as a
success, because none of them survived. Per-row results are the declared
BatchOperationResult shape: failures ride row.errors (an ApiError[] —
message in errors[0].message), records ride row.data when
returnRecords: true, and row.index correlates the row to the request array.
Error Handling
All errors follow a standardized format. The SDK throws a real Error with
those fields attached to it, so under strict (which implies
useUnknownInCatchVariables) the catch binding is unknown and you must
narrow before reading them — the SDK exports no type guard of its own, so
declare the shape you rely on and test for it:
/**
* What the SDK attaches to the `Error` it throws for a non-2xx response.
* `httpStatus` is always set; the rest are present only when the server
* sent them.
*/
interface ObjectStackApiError extends Error {
httpStatus: number;
code?: string;
category?: string;
retryable?: boolean;
details?: unknown;
fields?: { field: string; code: string; message: string }[];
}
function isApiError(error: unknown): error is ObjectStackApiError {
return error instanceof Error && 'httpStatus' in error;
}
try {
await client.data.create('todo_task', { subject: '' });
} catch (error) {
if (!isApiError(error)) throw error; // not a server response — rethrow
console.error(error.code); // 'VALIDATION_FAILED'
console.error(error.httpStatus); // 400
console.error(error.category); // optional — set only when the server sent it
console.error(error.retryable); // optional — set only when the server sent it
console.error(error.details); // { ... }
}httpStatus is the discriminator because the client sets it on every error
it throws for a server response, and on none of the errors it throws before one
(a runtime without Response.body, a missing environmentId). Do not narrow to
the exported StandardError interface: that type describes the server's error
envelope, its code is the closed StandardErrorCode enum (which does not
contain the ledger-registered VALIDATION_FAILED these examples branch on),
and it carries no fields.
error.code is the semantic code as a string whenever the server sent one —
the numeric HTTP status lives on error.httpStatus and nowhere else, and is set
even when no code was. This holds regardless of which server surface answered:
the REST server replies with a flat { error, code, fields } body and the runtime dispatcher replies with a wrapped
{ success, error: { code, message, httpStatus, details } } body, and the
client normalizes both before throwing. (Dispatcher bodies from servers older
than #3842 put the status in error.code and the semantic code in
error.details.code; that fallback read was retired in #4007 — SDK and server
ship as one release group, so that pairing is not a supported deployment.)
Per-field validation errors
A validation failure additionally carries error.fields — one entry per
offending field, ready to attach to the inputs that produced them:
try {
await client.data.create('contact', { email: 'not-an-email' });
} catch (error) {
if (!isApiError(error)) throw error;
if (error.code === 'VALIDATION_FAILED') {
for (const f of error.fields ?? []) {
showFieldError(f.field, f.message); // 'email', 'email must be a valid email address'
}
}
}error.fields is left unset (not []) when the server reported no
per-field detail, so if (error.fields) is a safe test for "this failure is
field-anchored".
Error Codes
error.code is drawn from a two-tier vocabulary (ADR-0112), and
packages/spec is the authority for the full set:
- Standard catalog — the closed
StandardErrorCodeenum (packages/spec/src/api/errors.zod.ts): generic conditions with platform-wide HTTP semantics. It does not grow when a service invents a code. - Ledger-registered codes — the service-specific codes each package
registers in
ERROR_CODE_LEDGER(packages/spec/src/api/error-code-ledger.zod.ts).
The exported ErrorCode schema is the union of the two, so a code being
absent from StandardErrorCode does not make it unofficial or unsupported:
VALIDATION_FAILED — the code the examples above branch on — is a
ledger-registered code, as are several others this page's examples use. The
table below is a hand-picked subset of the codes you are most likely to branch
on, not either tier in full; the Tier column says which set a row comes
from.
| Code | Tier | HTTP | Category | Retryable | Description |
|---|---|---|---|---|---|
VALIDATION_ERROR | standard | 400 | validation | No | The request was refused before any record was validated — a repeated query parameter, a filter outside the allowlist, a malformed argument |
VALIDATION_FAILED | ledger | 400 | validation | No | A record failed validation on a write; carries fields[]. This — not VALIDATION_ERROR — is what a per-field failure arrives as |
INVALID_QUERY | standard | 400 | validation | No | Malformed query expression |
UNAUTHENTICATED | standard | 401 | authentication | No | Authentication required |
PERMISSION_DENIED | standard | 403 | authorization | No | Insufficient permissions |
RESOURCE_NOT_FOUND | standard | 404 | not_found | No | Resource does not exist |
RATE_LIMIT_EXCEEDED | standard | 429 | rate_limit | Yes | Too many requests |
INTERNAL_ERROR | standard | 500 | server | Yes | Unexpected server error |
SERVICE_UNAVAILABLE | standard | 503 | server | Yes | Service temporarily unavailable |
NOT_IMPLEMENTED | standard | 501 | server | No | Service not installed (plugin missing) |
Category and Retryable above are the semantic classification of each
condition. Neither is guaranteed on the wire — as the narrowing example shows,
error.category and error.retryable are present only when the server sent
them, and the REST server's per-field validation envelope sends neither.
Flow execution errors
client.automation.execute() rejects when the flow does not run — it does
not resolve with an inner { success: false } — so branch on the thrown error,
not on the resolved value. Its refusal codes are ledger-registered rather than
members of the standard catalog above, which makes them service-specific, not
unofficial.
Only a run that actually dispatched has artefacts to report, so details —
unknown on the shared shape, because what rides in it is per-surface — is
narrowed one step further here:
/**
* What the automation door attaches to `details` on a `FLOW_FAILED`: the run's
* own artefacts. A flow that never dispatched (`FLOW_DISABLED`,
* `FLOW_NO_START_NODE`, `FLOW_INPUT_SCHEMA_INVALID`, or an unknown flow's 404)
* has no author text and no node log to point at, which is why both members
* are optional.
*/
interface FlowFailureDetails {
errorMessage?: string; // the flow author's own text (`flow.errorMessage`)
summary?: unknown; // per-node accounting (spec's `FlowRunSummary`)
}
function hasFlowFailureDetails(
error: ObjectStackApiError,
): error is ObjectStackApiError & { details: FlowFailureDetails } {
return typeof error.details === 'object' && error.details !== null;
}
try {
await client.automation.execute('order_approval', { params: { orderId } });
} catch (err) {
if (!isApiError(err)) throw err; // not a server response — rethrow
console.error(err.httpStatus); // 409 | 422 | 400 | 404
console.error(err.code); // 'FLOW_DISABLED' | 'FLOW_NO_START_NODE' | 'FLOW_INPUT_SCHEMA_INVALID' | 'FLOW_FAILED'
if (err.code === 'FLOW_FAILED' && hasFlowFailureDetails(err)) {
console.error(err.details.errorMessage); // the flow author's own text
console.error(err.details.summary); // which node failed
}
}Configuration
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;
}React Hooks
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>);
}Available Hooks
| 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 |
Testing
The client SDK includes comprehensive unit and integration tests to ensure reliability and protocol compliance.
Unit Tests
cd packages/client
pnpm testUnit tests use mocks to verify client behavior without requiring a server.
Integration Tests
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: the client's unit tests are what cover the API namespaces broadly. Integration tests against a live server are far narrower — only discovery/connection is written today; the remaining namespaces are listed as a backlog in packages/client/tests/integration/README.md. Per-route coverage is asserted by code in CI — packages/runtime/src/route-ledger.ts plus its conformance tests — not by integration tests and not by a hand-maintained table.
Protocol Compliance Documentation
For detailed information about the client's protocol implementation:
- Protocol Compliance Matrix (retired) — Historical only: retired 2026-07-27 by the #3563 route audit, which disproved its "fully compliant" verdict (27 routes had no SDK expression on the day it still claimed one). Kept so inbound links don't break; coverage today is asserted in CI by
packages/runtime/src/route-ledger.tsand the conformance tests on both sides - Integration Tests — What client-server integration coverage exists today, and the backlog of namespaces still unwritten
- Package README — Developer navigation and API reference