@uluops/registry-sdk
v0.64.2
Published
SDK for the UluOps Registry API - manage AI workflow definitions
Maintainers
Readme
UluOps · The operations layer for agentic work
@uluops/registry-sdk
TypeScript SDK for the UluOps Registry API. Manage AI workflow definitions including agents, commands, workflows, and pipelines.
Quick Start
Note: Examples use TypeScript syntax. Run with tsx (
npx tsx script.ts) or compile withtscfirst. Node examples usingprocess.envneed@types/nodewhen compiled withtsc(npm install -D @types/node). A running Registry API server is required — see Environment Variables to configure the base URL.
Node.js (Recommended)
Set your API key in the environment (or a .env file), then use createClientFromEnvironment() to auto-discover credentials:
export ULUOPS_API_KEY=ulr_your-api-key-hereimport { createClientFromEnvironment } from '@uluops/registry-sdk/config';
const client = createClientFromEnvironment();
// List agent definitions
const { definitions: agents } = await client.definitions.list({ type: 'agent' });
// Get a specific definition (latest published version; pass a version to pin one)
const def = await client.definitions.get('agent', 'code-validator');
// Create a new definition
const newDef = await client.definitions.create('agent', 'my-agent', {
yaml: 'agent:\n interface:\n name: my-agent\n version: 1.0.0',
});Browser / Explicit Config
The RegistryClient constructor is browser-safe and does not read environment variables. Pass a short-lived session token supplied at runtime by your backend; do not bundle a long-lived API key into browser code:
import { RegistryClient } from '@uluops/registry-sdk';
function createBrowserClient(sessionToken: string): RegistryClient {
return new RegistryClient({ sessionToken });
}Security: Never hardcode API keys in source code. Use environment variables, secret managers, or server-side proxies to inject credentials at runtime.
Table of Contents
- Features
- Installation
- Authentication
- TypeScript Support
- API Reference
- Environment Variables
- Error Handling
- Advanced Usage
- Quality metric contracts (F04)
- License
Features
- Full API Coverage: Access all registry endpoints across 13 operation domains
- Browser Compatible: Constructor is browser-safe — use in Next.js, React, or any browser bundler
- Type-Safe: Complete TypeScript definitions for API operations with Zod runtime validation
- Dual Authentication: API key (preferred) and JWT session support
- Automatic Retries: Exponential backoff for transient errors (502, 503, 504, 429, network failures)
- Error Hierarchy: Typed errors for precise error handling
- Server-Side Normalization: Request runtime-ready normalized definitions via
{ normalize: true }— transforms computed server-side - Subpath Exports: Import only what you need (
/types,/errors,/config)
Installation
Note: This package is ESM-only. CJS environments (
require()) are not supported. Ensure your project uses"type": "module"or an ESM-compatible bundler.
# npm
npm install @uluops/registry-sdk zod
# yarn
yarn add @uluops/registry-sdk zod
# pnpm
pnpm add @uluops/registry-sdk zodzod ^4.6.7 is a peer dependency since 0.63.0. Your app supplies one zod, and this SDK validates responses with it, so the app holds exactly one copy. npm 7+ and pnpm 8+ install a missing peer automatically, but yarn classic only warns, so the commands above name zod explicitly. An app still on zod 3 gets an ERESOLVE peer conflict until it moves to zod 4.
Requirements:
- Node.js 20.3.0 or higher (server-side — matches
engines; required by@uluops/sdk-core) - Any modern browser with
fetchsupport (client-side) - TypeScript 5.0+ (for TypeScript users)
Authentication
The SDK supports two authentication methods:
API Key Authentication (Recommended)
API keys provide persistent authentication without session management. Keys must start with the ulr_ prefix.
Node.js — use environment discovery (reads ULUOPS_API_KEY from env, .env files, and ~/.uluops/credentials.json):
import { createClientFromEnvironment } from '@uluops/registry-sdk/config';
const client = createClientFromEnvironment();
console.log(client.isAuthenticated()); // true
console.log(client.getAuthType()); // 'api_key'Browser / Explicit — pass the key directly (do not hardcode in source):
import { RegistryClient } from '@uluops/registry-sdk';
const client = new RegistryClient({
apiKey: process.env.ULUOPS_API_KEY,
// Optional: point at a non-production registry (local dev, staging, a stub server).
// baseUrl: 'http://localhost:3001/api/v1',
// authBaseUrl: 'http://localhost:3100/api/v1', // where login()/refresh go (the ops API)
});Create an API key in the UluOps dashboard at https://app.uluops.ai (Settings → API keys). baseUrl and authBaseUrl default to production (see Environment Variables for the NODE_ENV=development defaults and the ULUOPS_REGISTRY_URL / ULUOPS_AUTH_URL overrides that createClientFromEnvironment() reads).
Session-Based Authentication
For interactive applications, use login() to obtain a session token programmatically.
Note: Constructing
new RegistryClient()without credentials is valid — the client starts unauthenticated and becomes authenticated afterlogin()succeeds. Any API call made before login will fail withUnauthorizedError.
// Login with email/password — no API key required
const sessionClient = new RegistryClient();
const { sessionToken, expiresAt } = await sessionClient.login('[email protected]', 'password');
// The client is now authenticated — subsequent requests use the session token
// Or pass an existing token directly
const tokenClient = new RegistryClient({
sessionToken: 'your-jwt-token',
});
// Clear the local session when done (does not revoke the token server-side)
sessionClient.clearLocalSession();The auth URL defaults to production (https://api.uluops.ai/api/v1).
Validating Credentials
Use the config utilities to check credentials before constructing a client:
import { isApiKey, validateCredentials, API_KEY_PREFIX } from '@uluops/registry-sdk/config';
const key = process.env.ULUOPS_API_KEY;
// Check that credentials are present (throws ValidationError if missing)
try {
validateCredentials({ apiKey: key });
} catch (error) {
console.error('No credentials found:', error.message);
process.exit(1);
}
// Validate API key prefix format (returns boolean)
if (!isApiKey(key!)) {
console.error(`Invalid API key format. Keys must start with '${API_KEY_PREFIX}'`);
process.exit(1);
}Credential Priority Chain
When using createClientFromEnvironment() (see Node.js Environment Discovery), credentials are loaded in the following order:
- Explicit arguments:
apiKey,sessionTokenpassed to the function - Environment variables:
ULUOPS_API_KEY,ULUOPS_SESSION_TOKEN - Local
.envfile: In the current working directory - Global credentials:
~/.uluops/credentials.json
Note: The
new RegistryClient()constructor uses only the config you pass directly. It does not read environment variables or credential files — this keeps it browser-safe. UsecreateClientFromEnvironment()for automatic credential discovery in Node.js.
TypeScript Support
The SDK is written in TypeScript with full type definitions. Import types directly:
// Main client
import { RegistryClient, type RegistryClientConfig } from '@uluops/registry-sdk';
// Types only
import type {
Definition,
DefinitionType,
DefinitionStatus,
DefinitionRef, // lightweight { type, name, version } reference, embedded in analytics/execution results
ValidationFieldError,
Model,
ModelCost, // pricing block, USD per million tokens; null = unpriced
PublicUser,
} from '@uluops/registry-sdk';
// Errors only
import {
RegistryApiError,
ValidationError,
NotFoundError,
RateLimitError,
} from '@uluops/registry-sdk/errors';
// Config utilities
import { loadCredentials, DEFAULT_BASE_URL } from '@uluops/registry-sdk/config';Package Exports
| Export Path | Contents | Browser Safe* |
|------------|----------|:---:|
| @uluops/registry-sdk | Main RegistryClient, RegistryHttpClient, auth strategies | Yes |
| @uluops/registry-sdk/types | TypeScript types (PascalCase), Zod runtime schemas (*Schema suffix), enum arrays (SCREAMING_SNAKE) | Yes |
| @uluops/registry-sdk/errors | Error classes and utilities | Yes |
| @uluops/registry-sdk/config | Configuration loaders, createClientFromEnvironment, constants | No (Node.js) |
| @uluops/registry-sdk/config/constants | SDK_VERSION, MAX_YAML_SIZE, ERROR_CODES, HTTP_STATUS, ENV_VARS, etc. | Yes |
* Browser Safe = no Node.js built-ins required (importable in any browser bundler). API calls from browsers require a server-side proxy — see Browser Usage.
API Compatibility
This SDK targets the UluOps Registry API v1 (/api/v1). The SDK version follows semver:
- Patch (0.1.x): Bug fixes, no API changes
- Minor (0.x.0): New features, backward-compatible
- Major (x.0.0): Breaking changes (method signatures, removed endpoints)
Note (pre-1.0): While this project is pre-1.0, minor releases may include breaking changes when the impact is demonstrably contained — for example, when fields were typed but never populated by the live API (see v0.31.0 / R12 envelope rewrite). The CHANGELOG ### Changed / ### Removed sections always carry the exact migration steps. Post-1.0 we will follow strict semver.
The base URL defaults to https://api.uluops.ai/api/v1/registry.
API Reference
Client Configuration
const client = new RegistryClient({
// Authentication (choose one)
apiKey: process.env.ULUOPS_API_KEY, // API key (preferred)
sessionToken: 'jwt-token', // Existing session token
email: '[email protected]', // Email for session auth (requires password)
password: 'secret', // Password for session auth (requires email)
// Connection settings
timeout: 30000, // Request timeout in ms (default: 30000)
retries: 3, // Retry count for transient errors (default: 3)
orgSlug: 'my-org', // Org for WRITES (sent as X-Org-Slug on non-GET requests only — see below)
debug: false, // Enable debug logging
// Callbacks
onTokenRefresh: (token) => { /* handle token refresh */ },
onRateLimitApproaching: (info) => {
console.warn(`Rate limit: ${info.remaining}/${info.limit} remaining, resets ${info.reset}`);
},
rateLimitThreshold: 0.1, // Fire when <10% remaining (default)
onRetry: ({ attempt, maxAttempts, error, delayMs }) => {
console.warn(`Retry ${attempt}/${maxAttempts} after ${delayMs}ms: ${error.message}`);
},
onSecurityEvent: (event) => { /* route to telemetry — see "Security Events" */ },
});Security Events
Since @uluops/[email protected], the client exposes a structured security-event channel. Pass onSecurityEvent to route the security-relevant events the client already observes to your telemetry sink. The handler is forwarded to the underlying sdk-core client; delivery is best-effort and fire-and-forget (a throwing handler is caught and logged, never propagated into request flow).
import { RegistryClient, type SecurityEvent } from '@uluops/registry-sdk';
const client = new RegistryClient({
apiKey: process.env.ULUOPS_API_KEY,
onSecurityEvent: (event: SecurityEvent) => {
switch (event.type) {
case 'auth_failure': siem.alert('auth_rejected', { authType: event.authType, requestId: event.requestId }); break;
case 'redirect_rejected': siem.alert('redirect_blocked', { origin: event.baseUrl }); break;
case 'token_refresh_failed': siem.alert('session_refresh_failed', {}); break;
case 'auth_strategy_replaced': siem.alert('credential_swapped', { from: event.previousType, to: event.newType }); break;
}
},
});| Event type | Fires when |
|--------------|-----------|
| auth_failure | A sent credential is rejected with 401 (and not transparently refreshed) |
| redirect_rejected | The configured origin returns a 3xx the SDK refuses to follow |
| token_refresh_failed | A session token refresh (re-login) is rejected |
| auth_strategy_replaced | The live credential is swapped via setAuthStrategy |
The registry SDK runs a separate authBaseUrl (login/refresh delegates to the ops API), so a redirect_rejected on the credential-carrying login POST is reported against authBaseUrl. A blocked redirect throws RedirectError (re-exported from @uluops/registry-sdk/errors, non-retryable — catch it with isRedirectError(e) where you previously caught NetworkError). The SecurityEvent union and member types are exported from the package root.
Client Instance Methods
| Method | Returns | Description |
|--------|---------|-------------|
| isAuthenticated() | boolean | Check if credentials are configured and valid |
| getAuthType() | 'api_key' \| 'session' \| null | Get the authentication strategy in use |
| login(email, password) | Promise<LoginResult> | Login with email/password via the ops API |
| clearLocalSession() | void | Clear the local session token (no server-side revocation) |
| getHttpClient() | RegistryHttpClient | Access the underlying HTTP client for custom requests |
Definitions (client.definitions)
Manage AI workflow definitions (agents, commands, workflows, pipelines).
list(query?)
List definitions with optional filters.
Use { name: 'code-validator' } for exact identifier matching, or
{ name: 'code-', match: 'prefix' } for a literal prefix. Punctuation, quotes,
%, _, and backslashes are preserved as data. name cannot be combined with
search or match: 'text'. Explicit text search uses
{ search: 'validation', match: 'text' }; explicit text queries require 1–100 printable ASCII characters before trimming and cannot be blank.
Keyword terms of at least three characters after sanitization use FULLTEXT over
name, display_name, and description. Short original searches fall back to
name/display_name; longer searches whose sanitized terms are shorter than
three characters fall back across those fields plus description. Tags remain
an independent OR-any filter. A row may include numeric relevance for actual
FULLTEXT matches; legacy and identifier results need not include a score.
Every operation with name or explicit match fetches /capabilities and requires
contracts.definitionSearch to advertise name-v1. Unsupported, missing or
malformed support throws UnsupportedDefinitionSearchContractError from
@uluops/registry-sdk/errors before the list request, with no fallback. Capability
authentication and transport errors remain intact. Legacy search does not negotiate.
| Parameter | Type | Description |
|-----------|------|-------------|
| type | DefinitionType | Filter by type ('agent', 'command', 'workflow', 'pipeline') |
| status | DefinitionStatus | Filter by status ('draft', 'published', 'deprecated', 'archived') |
| domain | Domain | Filter by domain |
| authorId | string | Filter by author user ID |
| visibility | Visibility | Filter by visibility |
| search | string | Keyword text search (legacy behavior when match is omitted) |
| name | string | Literal identifier, trimmed and lowercased, 1–100 printable ASCII characters before trimming; implies exact matching |
| match | 'exact' \| 'prefix' \| 'text' | Explicit search mode; exact/prefix require name, text requires nonblank search |
| tag | string \| string[] | Independent OR-any tag filter; tags are not keyword searched |
| isFork | boolean | true = only forks, false = only originals |
| authorshipType | AuthorshipType | Filter by authorship ('human', 'agent', 'collaborative', 'automated') |
| agentType | AgentType | Filter by agent type |
| tier | Tier | Filter by subscription tier |
| sortBy | SortField | Sort field — 'name', 'createdAt', 'updatedAt', 'executionCount' (total runs), 'uniqueExecutionCount' (distinct users) |
| sortOrder | SortOrder | 'asc' or 'desc' |
| limit | number | Max results (default: 50, max: 200) |
| offset | number | Pagination offset |
const agents = await client.definitions.list({
type: 'agent',
status: 'published',
limit: 20,
});List items carry risk scalars (since 0.44.0): each DefinitionListItem
includes riskLevel, scanStatus, and deepStatus — the safety verdict
denormalized to the list grain, so agents and tools can rank or warn during
discovery without a per-definition get. The P6 sentinel rule applies here
too: gate on isListVerdictTrustworthy(item) before treating
riskLevel: 'none' as clean — an absent/null triple means not yet scanned
(pending), and 'none' beside scanStatus: 'failed' means could not
determine, never clean.
import { isListVerdictTrustworthy } from '@uluops/registry-sdk';
for (const item of agents.definitions) {
if (!isListVerdictTrustworthy(item)) {
console.warn(`${item.name}: scan pending/incomplete — verdict unknown`);
} else if (item.riskLevel !== 'none') {
console.warn(`${item.name}: ${item.riskLevel} risk signals — inspect riskProfile via get()`);
}
}Verdict currency (since 0.45.0): list items also carry
analyzerStale: boolean | null — whether the verdict predates the registry's
current analyzer (detector set), computed server-side at read time. null =
never scanned, true = stale (the registry already demotes such rows in
search ordering), false = current. Informational only: it is
deliberately not part of isListVerdictTrustworthy — a stale verdict is
still a real verdict. Surface it as a disclosure:
if (item.analyzerStale) {
console.info(`${item.name}: risk verdict predates the current analyzer — may be outdated`);
}get(type, name, version?, options?)
Get a definition by type, name, and optional version. name is a bare name: it means the org that
first published it, even with orgSlug configured — see
orgSlug scopes definition writes for reading your own org's
copy of a name another org published first.
| Option | Type | Description |
|--------|------|-------------|
| includeYaml | boolean | Include raw YAML content in response |
| includeRuntime | boolean | Include rendered markdown in response |
| includeRefs | boolean | Include dependency references |
// Get latest version
const def = await client.definitions.get('agent', 'code-validator');
// Get specific version
const def = await client.definitions.get('agent', 'code-validator', '1.11.1');
// Include YAML and rendered markdown
const full = await client.definitions.get('agent', 'code-validator', '1.11.1', {
includeYaml: true,
includeRuntime: true,
});create(type, name, body)
Create a new draft definition.
const def = await client.definitions.create('agent', 'my-agent', {
yaml: `
agent:
interface:
name: my-agent
version: 1.0.0
description: My custom agent
`,
visibility: 'public',
});update(type, name, version, body)
Update an existing draft definition.
const def = await client.definitions.update('agent', 'my-agent', '1.0.0', {
yaml: updatedYaml,
});delete(type, name, version)
Delete a draft definition. Published definitions cannot be deleted.
await client.definitions.delete('agent', 'my-agent', '1.0.0');publish(type, name, version)
Publish a draft definition to make it available.
Returns PublishResult ({ definition, warnings }) since 0.29.0 — not a bare
Definition. warnings is always an array (possibly empty) of non-fatal notices: the
definition is published. Examples: a recommends reference that did not resolve, a command's
UNRESOLVED_AGENT, safety-scan failures. A translation failure is not one of them: since
registry-api 0.51.1 it refuses the publish with 422 (UnprocessableError). SDKs before 0.64.0
documented a TRANSLATION_FAILED warning that the registry no longer emits. These notices are
not preview's publish.warnings, which are validation diagnostics.
const result = await client.definitions.publish('agent', 'my-agent', '1.0.0');
console.log(result.definition.status); // 'published'
if (result.warnings.length > 0) {
console.warn('Publish warnings:', result.warnings.map((w) => w.code));
}deprecate(type, name, version, body)
Deprecate a published definition.
const def = await client.definitions.deprecate('agent', 'my-agent', '1.0.0', {
reason: 'Replaced by my-agent-v2',
successor: '[email protected]',
});archive(type, name, version)
Archive a deprecated definition. This is a terminal state that removes the definition from discovery.
await client.definitions.archive('agent', 'my-agent', '1.0.0');Safety Analysis (definition.riskProfile)
Definitions may carry a riskProfile populated by the registry's safety scanner
at publish time. It is undefined/null when the version has not been scanned.
All safety types are exported from the package root and from
@uluops/registry-sdk/types.
Gate on
isVerdictTrustworthybefore trusting the verdict. A profile whose sync scan failed to complete still carriesaggregateRiskLevel: 'none'— but that'none'is a sentinel meaning "could not determine", not a clean verdict. The same applies when the background deep analysis errored (deep.status: 'error'): the aggregate stays at the sync level, so a sync-clean definition whose deep audit crashed would read as clean. ReadingaggregateRiskLeveldirectly would render either crash as safe. The exportedisVerdictTrustworthy(profile)predicate returnsfalsefor a failed sync scan (scanStatus: 'failed'), an errored deep analysis (deep.status: 'error'), or an absent profile — treat those as "not fully analyzed", never as clean. (deep: null— skipped or not yet run — stays trusted.)
import {
isVerdictTrustworthy,
type RiskProfile,
type RiskLevel,
type SafetySignal,
} from '@uluops/registry-sdk';
const def = await client.definitions.get('agent', 'my-agent', '1.0.0');
if (!isVerdictTrustworthy(def.riskProfile)) {
// Never scanned, or the scan failed — the verdict cannot be trusted.
console.warn('Safety verdict unavailable — definition not fully analyzed.');
} else if (def.riskProfile!.aggregateRiskLevel !== 'none') {
for (const signal of def.riskProfile!.sync.signals) {
console.warn(`[${signal.severity}] ${signal.title} — ${signal.detail}`);
}
}| riskProfile field | Type | Description |
|---------------------|------|-------------|
| aggregateRiskLevel | RiskLevel ('none' \| 'medium' \| 'high') | Combined risk across sync + deep analysis. A sentinel ('none'), not a verdict, when scanStatus is 'failed' or deep.status is 'error' — gate with isVerdictTrustworthy. |
| scanStatus | 'complete' \| 'failed' (optional) | Sync scan outcome. Absent on legacy rows → treat as 'complete'. When 'failed', the verdict could not be determined. |
| scanFailedReason | 'parse_error' \| 'timeout' \| 'internal' (optional) | Present when scanStatus === 'failed'. |
| sync | SyncScanResult | Synchronous publish-time scan: capabilities, signals[], riskLevel |
| deep | DeepAnalysisResult \| null | Background deep analysis (findings[]), null until it runs. deep.status: 'error' means the deep verdict could not be determined (empty findings is a sentinel, not clean); deep.errorReason names why, from a server-owned vocabulary that grows — treat it as display text. deep.droppedFindings (since 0.49.0), when present, counts agent findings the persisted vocabulary could not hold. |
| lastUpdated | string | ISO timestamp of the most recent scan |
Risk levels are none, medium, or high — there is deliberately no low
(a signal always means something). Risk reflects evidence of misuse, not
capability count or provenance.
At the list grain (since 0.44.0), the same verdict travels as three flat
scalars on DefinitionListItem — riskLevel, scanStatus, deepStatus —
gated by isListVerdictTrustworthy(item) (the list-grain twin of
isVerdictTrustworthy). The full riskProfile remains a detail-fetch field.
Versions (client.versions)
Manage definition version history.
list(type, name, options?)
List all versions of a definition. Each item carries per-version author identity:
createdBy (user ID), createdByName (resolved username, null for deleted users),
and provenance (the version's contributor record) — so the version history tells
the full authorship story even when different org members published different versions.
Items also carry status (draft/published/deprecated, registry API ≥0.54.0) —
so you can finally tell which version is live without a per-version lookup.
const { versions } = await client.versions.list('agent', 'code-validator', {
limit: 20,
offset: 0,
});
for (const v of versions) {
console.log(`${v.version} [${v.status ?? 'unknown'}] by ${v.createdByName ?? v.createdBy} (${v.changeType ?? 'initial'})`);
}diff(type, name, fromVersion, toVersion, options?)
Compare two versions showing changes. The response shape depends on the format option:
Select diffContract: 'combined-v1' with format: 'unified' for an applicable patch;
add full: true for exact sourceYaml/targetYaml alongside it. See
combined unified version diffs for an example.
// Section-level summary (default)
const summary = await client.versions.diff('agent', 'code-validator', '1.0.0', '2.0.0');
console.log(summary.sectionsAdded, summary.sectionsRemoved, summary.sectionsModified);
// Unified text diff
const unified = await client.versions.diff('agent', 'code-validator', '1.0.0', '2.0.0', {
format: 'unified',
});
console.log(unified.unified); // string containing unified diff
// Field-level diff with suggested semver bump
const fields = await client.versions.diff('agent', 'code-validator', '1.0.0', '2.0.0', {
format: 'fields',
});
console.log(fields.suggestedBump); // 'major' | 'minor' | 'patch'
// Full raw YAML
const full = await client.versions.diff('agent', 'code-validator', '1.0.0', '2.0.0', {
full: true,
});
console.log(full.fromYaml, full.toYaml);Validation (client.validation)
Validate definition YAML before creating.
validate(type, yaml)
Validate YAML against the schema.
const result = await client.validation.validate('agent', yamlContent);
if (result.valid) {
console.log('YAML is valid');
} else {
console.log('Errors:', result.errors);
}
for (const w of result.warnings ?? []) console.log(`${w.code} ${w.path ?? '(document)'}: ${w.message}`);
console.log(result.closure); // agents: { mode: 'warn', demoted: 0 }Since 0.64.0 the result also keeps warnings, schemaVersion, validatedAt and, for agents,
closure: { mode, demoted }. Earlier SDKs stripped them. Against a registry-api with ADL v1.20 P0c
deployed, agent diagnostics carry the factory's codes (UNKNOWN_KEY, UNKNOWN_FAILURE_MODE,
UNRENDERED_KEY, …) instead of a uniform SCHEMA_ERROR, a path that is a JSON pointer naming the
offending key, and key / demotedFrom where they apply. /validate runs the publish gate's
validation under the same closure mode, so its valid predicts that one of publish's checks.
Publish also refuses on blocking dependency references and on a translation failure; for the whole
decision, read preview's publish.publishable (below).
code, severity and closure.mode are open strings: the factory owns those vocabularies and a
release may add to them, so match the values you know and treat others as unknown rather than
exhaustively switching. path is optional: whole-document errors (PAYLOAD_TOO_LARGE,
INVALID_ENCODING) carry none.
Dependencies (client.dependencies)
Query dependency relationships between definitions.
v0.31.0 — shape change (live-tests T2 §3.5, R12):
dependencies.get()anddependencies.getDependents()now return real envelopes. Prior versions exportedDependencyGraph({nodes?, edges?, cycleDetected?, cycles?}) — every field was optional, so the schema silently parsed every real API response as{}. The typesDependencyGraphandDependencyEdgehave been removed; the new shapes areDependencyGraphResponse(recursivegraph+ flat list) andDependentsResponse. Consumers who accessed.nodes/.edges/.cycleDetectedneed to migrate to.graph.dependencies/.flat/ (cycle-detect client-side if needed). In practice every pre-fix consumer was reading{}so real-world breakage is small. See the CHANGELOG for the full migration story.
get(type, name, version, options?)
Get the dependency graph for a definition. Returns DependencyGraphResponse:
{definition, graph: DependencyNode (recursive), flat: FlatDep[], totalCount, maxDepth}.
const result = await client.dependencies.get('workflow', 'my-workflow', '1.0.0', {
maxDepth: 3,
});
// result.graph is the root DependencyNode (recursive tree)
console.log('Root:', result.graph.name, result.graph.version);
console.log('Direct deps:', result.graph.dependencies.length);
// result.flat is a pre-flattened list with depth labels for table rendering
for (const dep of result.flat) {
console.log(` depth ${dep.depth}: ${dep.type}/${dep.name}@${dep.version}`);
}
console.log('Total transitive deps:', result.totalCount);
console.log('Max depth resolved:', result.maxDepth);
// Or walk the recursive tree directly:
function walk(node: DependencyNode, depth = 0) {
const ctx = node.context ? ` [${node.context}]` : '';
console.log(' '.repeat(depth) + `${node.type}/${node.name}@${node.version}${ctx}`);
for (const child of node.dependencies) walk(child, depth + 1);
}
walk(result.graph);getDependents(type, name, version)
Get definitions that depend on this one. Returns DependentsResponse:
{definition, dependents: Dependent[], totalCount}. Each Dependent carries a
context field describing how it references the target (e.g. "invokes.agent",
"phase validate", "dependencies.requires").
const result = await client.dependencies.getDependents('agent', 'code-validator', '1.0.0');
console.log(`${result.totalCount} dependents of`,
`${result.definition.type}/${result.definition.name}@${result.definition.version}`);
for (const dep of result.dependents) {
// dep.context describes how the dependent uses this definition
console.log(` ${dep.type}/${dep.name}@${dep.version} ← ${dep.context}`);
}Forks (client.forks)
Fork definitions to create derivatives.
create(type, name, version, body)
Fork a definition to a new name.
const forked = await client.forks.create('agent', 'code-validator', '1.0.0', {
name: 'my-code-validator',
});isForkable(type, name, version)
Check if a definition can be forked.
const check = await client.forks.isForkable('agent', 'code-validator', '1.0.0');
if (check.canFork) {
console.log('Can fork!');
}getAncestry(type, name, version)
Get the fork lineage for a definition. Returns { isFork, fork, source, sourceAvailable, chain, depth, root } — if the definition is a fork, fork is the fork record and source is a slim summary of the live source definition.
On registry API ≥0.54.0 the response also carries the full ancestry in one call: chain (one entry per ancestor, immediate parent first, root last — each with the durable fork-record snapshot plus the live source summary when that ancestor still exists), depth (chain length), and root (the fork tree's origin, null if deleted). No more one-round-trip-per-level walking.
source becomes null (and sourceAvailable becomes false) once the source is deleted. The origin is still readable from the durable snapshot on the fork record — fork.sourceType / fork.sourceName / fork.sourceVersion — captured at fork time and surviving source deletion (registry API ≥ V1 2026-06-16).
const lineage = await client.forks.getAncestry('agent', 'my-validator', '1.0.0');
if (lineage.isFork) {
if (lineage.sourceAvailable) {
console.log('Forked from (live):', lineage.source?.name);
} else {
// Source deleted — origin still known from the durable snapshot.
console.log('Forked from (origin):', `${lineage.fork?.sourceName}@${lineage.fork?.sourceVersion}`);
}
console.log('Forked at:', lineage.fork?.forkedAt);
// Full ancestry in one call (API >=0.54.0):
for (const hop of lineage.chain ?? []) {
console.log(` ancestor: ${hop.sourceName}@${hop.sourceVersion}${hop.sourceAvailable ? '' : ' (deleted)'}`);
}
console.log('Tree root:', lineage.root?.name ?? '(deleted)', 'depth:', lineage.depth);
}list(type, name, version)
List all forks of a definition.
const result = await client.forks.list('agent', 'code-validator', '1.0.0');
console.log(result.totalForks); // 2
result.forks.forEach(({ fork, definition }) => {
console.log(definition?.name, fork.forkedAt);
});Executions (client.executions)
Record executions and query aggregated statistics.
record(type, name, version, body)
Record an execution of a definition — the write side that feeds execution
analytics. Idempotent: if body.runId is provided and already recorded, the
existing count is returned (duplicate: true) without double-counting.
| body field | Type | Description |
|--------------|------|-------------|
| source | string | Attribution label — 'cli', 'api', 'sdk', 'mcp', or any custom string |
| runId | string? | Optional idempotency key; repeat calls with the same runId are deduplicated |
const result = await client.executions.record('agent', 'code-validator', '1.0.0', {
source: 'sdk',
runId: 'run-abc-123',
});
console.log(result.recorded, result.duplicate, result.executionCount);Returns RecordExecutionResult: { recorded, duplicate, executionCount, definition }.
getStats(type, name, version, window?)
Get aggregated execution statistics. No authentication required.
const stats = await client.executions.getStats('agent', 'code-validator', '1.0.0', 60);
console.log(`Total executions: ${stats.totalCount}`);
console.log(`Recent executions: ${stats.recentCount}`);
console.log(`Window: ${stats.windowMinutes} minutes`);Stars (client.stars)
Star and unstar definitions. Stars are tracked per-user per-definition (not per-version). All operations are idempotent and require authentication.
getStatus(type, name, version?)
Check if the authenticated user has starred a definition.
const status = await client.stars.getStatus('agent', 'code-validator');
console.log(status.starred); // true
console.log(status.starCount); // 42star(type, name, version?)
Star a definition. No-op if already starred.
const result = await client.stars.star('agent', 'code-validator');
console.log(result.starCount); // 43unstar(type, name, version?)
Unstar a definition. No-op if not starred.
const result = await client.stars.unstar('agent', 'code-validator');
console.log(result.starCount); // 42Translation (client.translation)
Manage definition translation between schema versions.
getVersion()
Get the current translator version.
const version = await client.translation.getVersion();
console.log(`Translator: ${version.translatorVersion}`);retranslate(type, name, version, options?)
Re-translate a definition with the latest translator. The result's changed
flag (registry API ≥0.54.0) distinguishes a correct no-op — work performed,
artifacts identical — from a retranslation that actually updated runtimeMd.
const result = await client.translation.retranslate('agent', 'my-agent', '1.0.0', {
createNewVersion: true,
});
if (!result.changed) {
console.log('Already current — translator ran and produced identical artifacts.');
}upgradeDefinition(type, name, body)
Upgrade a legacy definition without storage/translation metadata to a new major
version. The result contains type, name, version, previousVersion,
translatorVersion and upgraded: true; newer APIs also return promptHash and
schemaVersion. It does not contain a nested definition or a changes map.
With the upgraded Registry API, current-format agent drafts and already translated definitions refuse with HTTP 409
and details.applicationState: "not_applied"; use publish/retranslate as appropriate.
If ResponseValidationError occurs after the upgrade write, its details report
applicationState: "unknown". Read the definition and list versions before retrying.
const result = await client.translation.upgradeDefinition('agent', 'legacy-agent', {
yaml: oldFormatYaml,
});Models (client.models)
Query the AI model catalog.
list(query?)
List available AI models.
const result = await client.models.list({
provider: 'anthropic',
tier: 'premium',
});
for (const model of result.models) {
// `limits.context` is the model's max context window (tokens). Optional:
// treat a missing window or `0` as "unknown" — some synced rows have no limit.
const window = model.limits?.context || undefined;
console.log(`${model.provider}/${model.modelId}: ${model.displayName} (window: ${window ?? 'unknown'})`);
}get(provider, modelId)
Get details for a specific model.
const model = await client.models.get('anthropic', 'claude-3-opus');
const routed = await client.models.get('openrouter', 'anthropic/claude-sonnet-4'); // id contains '/'
console.log(model.capabilities);
console.log(model.limits); // { context: 200000, output: 4096 }
console.log(model.cost); // { input: 3, output: 15, cacheRead: 0.3, ... } — USD per MILLION tokensCapabilities (model.capabilities) — vision, tools, streaming, reasoning,
structuredOutput, structuredOutputWithTools; each optional, and the SDK never invents one: absent
means the response did not say. false does not reliably mean "cannot": the registry serves
reasoning (and vision/tools) as false when its own record is missing or unparseable, so treat
false as "not known to", not "known not to". reasoning means the model can think before
answering (extended thinking) — whether a run does is the caller's choice. extendedThinking is a
deprecated alias of reasoning, filled from it on every model the SDK returns; it was always
undefined before 0.61.0, because the registry serves reasoning. The alias is read-only: filter
lists with capability: 'reasoning' — the registry ignores capability: 'extendedThinking' and
returns the unfiltered list. If you parse responses yourself with the exported modelSchema (from
@uluops/registry-sdk/types), apply normalizeCapabilities(model.capabilities) (from
@uluops/registry-sdk) to get the same alias.
Ids containing / (every OpenRouter slug) are fetched with the registry's query-string lookup
(GET /models/lookup?provider=&modelId=, registry API 2026-10-03+). The path form cannot carry
them: the registry's edge decodes %2F before routing. Ids without / use the path form as before.
On a 404, error.details.reason says which kind: 'model', 'alias' or 'route' (the request
matched no endpoint, for example against an older registry without the lookup route).
Pricing (model.cost) — present on models from list(), get(), and
resolveAlias() (registry API 2026-07-26+). Rates are USD per million tokens
(models.dev convention). null or absent means unpriced — the registry only emits
the block when both input and output rates exist; it never fabricates
{input: 0, output: 0}, so a cost of 0 never means "unknown". sourceUpdatedAt is
the models.dev per-model last_updated date (model-entry provenance, not a
cost-specific capture date) — treat rates without it as provenance-unknown.
if (model.cost) {
const usd = (inputTokens * model.cost.input + outputTokens * model.cost.output) / 1e6;
}listProviders()
List all model providers.
const providers = await client.models.listProviders();listAliases()
List model aliases (e.g., 'latest', 'opus').
const aliases = await client.models.listAliases();resolveAlias(alias)
Resolve an alias to a concrete model.
const resolution = await client.models.resolveAlias('opus');
console.log(`${resolution.alias} → ${resolution.target}`);An alias containing / (e.g. ~anthropic/claude-fable-latest) uses the query-string form,
GET /models/resolve?alias=.
Languages (client.languages)
Access definition language schemas (ADL, CDL, WDL, PDL).
list()
List all definition languages with current version info.
const result = await client.languages.list();
for (const lang of result.languages) {
console.log(`${lang.abbreviation} v${lang.currentVersion}: ${lang.displayName}`);
}get(id)
Get a definition language with its full JSON Schema.
const adl = await client.languages.get('adl');
console.log(adl.schema.title); // "Agent Definition Language (ADL) Schema"
console.log(adl.schema.version); // "1.16.0"
// adl.schema.content contains the full JSON Schema objectUsers (client.users)
Query public user profiles.
get(id)
Get a public user profile by ID.
const user = await client.users.get('user-uuid');
console.log(user.username, user.name);batch(ids)
Batch lookup multiple users (max 100).
const users = await client.users.batch(['id1', 'id2', 'id3']);
console.log(users['id1']?.username);
// Unknown IDs return null — the key is present but the value is null
console.log(users['nonexistent-id']); // nullRender (client.render)
Get rendered definition output.
get(type, name, version, options?)
Get the fully rendered/resolved definition. Pass "latest" as the version to resolve to the most recent published version.
// Get latest published version
const rendered = await client.render.get('agent', 'code-validator', 'latest');
console.log(rendered.markdown);
// Select the declared-only profile explicitly (registry on factory >= 0.81.0: only what the
// definition declares). Calls that omit it get the registry's stored profile, 'uluops-full'.
const core = await client.render.get('agent', 'code-validator', 'latest', {
renderProfile: 'core',
});
// Get specific version
const specific = await client.render.get('agent', 'code-validator', '1.5.0');
// With render profile (optional: 'core' or 'uluops-full')
const full = await client.render.get('agent', 'code-validator', 'latest', {
renderProfile: 'uluops-full',
});
// Multi-target render (for OpenCode, Codex, Gemini adapters)
const adapted = await client.render.get('agent', 'code-validator', 'latest', {
target: 'opencode', // Target harness format
model: 'gpt-5.3', // Model override for target envelope
});selectedModel, when reported by the API, is the actual model value in the rendered
artifact envelope (including aliases). It is null when the artifact has no model
envelope and absent when an older API does not report selection. It does not assert
provider resolution or that the requested override was honored. target,
renderProfile, promptHash and adapter warnings retain their existing semantics.
Since 0.62.0 the result also carries the renderer fields registry-api returns. Earlier SDKs stripped them silently.
factoryVersion: the factory version that produced the bytes.renderStack:{ factory, nunjucks, yaml }.- On ADL renders,
resolvedProfile,scaffoldinganddeclaredOutputShape.
ADL v1.20 closure fields (0.64.0; served by registry-api with P0c deployed, absent before):
validation: the render's own validation, grouped by code, key, severity and demotion ({ mode, schemaVersion, valid, demoted, groups[] }, each group with at most tenpathsand a fullcount). It never appears inwarnings. On/rendera stored agent re-renders underlegacy, sovalidationis the registry's verdict on how it reads this stored row, not on whether the same YAML would publish today.validation.valid: truehere is compatible with/validatereturningvalid: falsefor that YAML:legacydemotes some errors the fresh gate keeps (schema conditions the pre-v1.20 baseline did not raise, and failure-mode labels outside the taxonomy); rule-layer errors stay errors in both. Checkvalidation.modeto tell which grammar you hold.closure:{ mode, demoted, legacyWording }.legacyWordingis set (unknown-keysorsome-errors) exactly when the render carries the legacy stamp. It says which regime a render ran in. To ask whether two renders are the same, comparelegacyWording(the registry compares only this field;modeanddemotedare reported, not compared). To ask whether the bytes are the same, comparepromptHashunder the samerenderStack: equallegacyWordingdoes not mean equal bytes, because the stamp's text carries counts./renderre-renders. Underlegacy, a stored agent the current schema would reject renders stamped, and itspromptHashdiffers from the storedruntimeMd's. That is expected, not staleness. A definition read (client.definitions.get…) returns the stored render'sclosure(nullfor rows rendered before P0c).- A
/renderthat cannot render answers 422 with the samevalidationandclosureobjects in the error'sdetails.validationanddetails.closure.
All string vocabularies in these objects are open strings, for the reason given under validate.
Pin a core hash on (definition version, renderStack), never on promptHash alone:
a lockfile change in the factory's nunjucks/yaml moves bytes under an unchanged factory
version. scaffolding: false means the render is declared-only. Under it,
declaredOutputShape: false means the agent declares no output contract.
const r = await client.render.get('agent', 'code-validator', 'latest', { renderProfile: 'core' });
const pinKey = `${'1.5.0'}|${r.renderStack?.factory}|${r.renderStack?.nunjucks}|${r.renderStack?.yaml}`;
if (r.scaffolding === false && r.declaredOutputShape === false) {
// free-form agent: supply your own output contract
}preview(type, body)
Preview render without saving.
const preview = await client.render.preview('agent', {
yaml: rawYaml,
});
if (preview.publish && !preview.publish.publishable) {
console.log(preview.publish.errors, preview.publish.dependencyBlockers);
}Against a registry-api with ADL v1.20 P0c deployed, preview also returns publish: what publishing
this YAML would decide — publishable, validation errors/warnings, dependencyBlockers/
dependencyWarnings and any translationError — plus, for agents, the validation and closure of
the verdict's publish-time translation. Those two describe that render, not the returned
markdown/promptHash: the preview render itself skips validation (so a draft with incomplete
fields renders) and carries no qualifier. They are absent when translation did not run or failed.
publish.warnings are validation diagnostics (an UNRENDERED_KEY, for example). They are not what
the real publish returns in PublishResult.warnings, which holds non-fatal notices such as an
unresolved recommends reference; the dependency side of that is previewed in
dependencyWarnings.
Each entry of dependencyBlockers / dependencyWarnings is a DependencyFinding: code
(DEPENDENCY_UNRESOLVED, DEPENDENCY_NOT_PUBLISHED, DEPENDENCY_MALFORMED,
DEPENDENCY_LIMIT_EXCEEDED), reason, severity, dependencyClass (requires blocks,
recommends warns), ref ({ type, name, version }, null when the reference could not be
parsed), paths (where the YAML names it), context, target (the referenced version's
{ version, status }, when found) and message. That deploy also changes who may call preview: it resolves the caller's org (personal
unless one is named), checks membership and requires the publisher role, and answers 403
otherwise.
Analytics (client.analytics)
Definition effectiveness, health grades, lineage, evolution, and cross-version comparison. Health scores are provisional pending a 90-day calibration study.
getEffectiveness(type, name, version?)
Get effectiveness metrics: pass rate, scores, taxonomy distribution, health score, and composition lift.
Quality numbers are voter-weighted (registry-api >= 0.52): one actor, one vote — a single
account cannot dominate them with run volume. metrics.provenance reports who stands behind
them (actorCount/voterCount, a provisional/established confidence label, and the
independent vs selfReported split — provenance.independent is the headline figure to
quote; selfReported is the author rating their own definition). For agents, quality is
participation-based (snapshot scores across every run the agent appears in) and
effectiveness.passRate is null by design — a run-level gate result cannot be attributed to
one constituent. uniqueUsers is the all-time distinct-actor count; provenance counts are
windowed.
const eff = await client.analytics.getEffectiveness('agent', 'code-validator');
console.log(eff.metrics.healthScore); // 67
console.log(eff.metrics.provenance?.independent?.runAvgScore); // 90.1 — the headline
console.log(eff.metrics.provenance?.confidence); // 'provisional' until 3+ qualifying actors
console.log(eff.metrics.effectiveness?.passRate); // null for agents (score-only quality)
console.log(eff.metrics.effectiveness?.resolutionRate); // 0–100, or null when no issue is mature yet
console.log(eff.metrics.effectiveness?.declinedRate); // wontfix share — reported, never scored
console.log(eff.metrics.compositionLift?.statistics?.estimand); // 'equal_weight_per_agent' — what ci95 is for
// Specific version
const v2 = await client.analytics.getEffectiveness('agent', 'code-validator', '2.0.0');getHealth(type, name, version?)
Get health grade (A-F) and issue profile with failure domain distribution.
const health = await client.analytics.getHealth('agent', 'code-validator');
console.log(health.grade); // 'B'
console.log(health.provisional); // true — weights unvalidated
console.log(health.caveats); // ['PROVISIONAL: ...']getEcosystemOverview()
Get ecosystem-wide overview: definition counts, aggregate health, top performers, needs-attention list.
const overview = await client.analytics.getEcosystemOverview();
console.log(overview.definitions.total); // 42
console.log(overview.effectiveness.topPerformers);getLineage(type, name)
Get the lineage graph: versions and forks as a tree with per-node health scores.
const lineage = await client.analytics.getLineage('agent', 'code-validator');
console.log(lineage.totalVersions); // 3
console.log(lineage.totalForks); // 1getEvolution(type, name)
Get version-over-version metrics timeline with trend detection.
const evo = await client.analytics.getEvolution('agent', 'code-validator');
console.log(evo.trend); // 'improving' | 'declining' | 'stable' | 'volatile' | 'insufficient_data'
console.log(evo.trendConfidence); // 'high'trend is read off the slope's 95% confidence interval, not the point estimate: volatile
means the interval spans both dead-zone edges, so no direction is supported.
getTranslation(type, name)
Get versions grouped by translator version with aggregate metrics.
const translation = await client.analytics.getTranslation('agent', 'code-validator');
for (const group of translation.groups) {
console.log(`${group.translatorVersion}: ${group.aggregateMetrics.avgPassRate}%`);
}compare(type, name, versions)
Compare effectiveness across 2-5 definition versions side-by-side.
const cmp = await client.analytics.compare('agent', 'code-validator', ['1.0.0', '1.1.0', '1.2.0']);
for (const v of cmp.versions) {
console.log(`${v.version}: pass=${v.passRate}%, health=${v.healthScore}`);
}getDiffImpact(type, name, fromVersion, toVersion)
Get structural diff combined with metric deltas between two versions. Deltas are observational, not causal.
const impact = await client.analytics.getDiffImpact('agent', 'code-validator', '1.0.0', '1.1.0');
console.log(impact.deltas.passRateDelta); // 15
console.log(impact.caveats); // ['OBSERVATIONAL: ...']Environment Variables
These variables are read by createClientFromEnvironment() and loadConfig() from the /config sub-path. The new RegistryClient() constructor does not read environment variables — pass config explicitly instead.
| Variable | Description | Default |
|----------|-------------|---------|
| ULUOPS_API_KEY | API key for authentication | - |
| ULUOPS_SESSION_TOKEN | JWT session token | - |
| ULUOPS_EMAIL | Email for session-based auth | - |
| ULUOPS_PASSWORD | Password for session-based auth | - |
| ULUOPS_ORG_SLUG | Org for writes (X-Org-Slug on non-GET requests only) | - |
| ULUOPS_REGISTRY_URL | Registry API base URL (baseUrl) | https://api.uluops.ai/api/v1/registry; http://localhost:3001/api/v1 when NODE_ENV=development |
| ULUOPS_AUTH_URL | Ops API base URL for login() / token refresh (authBaseUrl); the registry has no auth endpoints of its own | https://api.uluops.ai/api/v1; http://localhost:3100/api/v1 when NODE_ENV=development |
| ULUOPS_DEBUG | Enable debug logging | false |
orgSlug scopes definition writes, not reads (since 0.57.0). The client sends X-Org-Slug
on definition writes only — create, update, delete, publish, deprecate, archive, retranslate,
upgrade, and fork (where it names the org the fork goes into) — to tell the registry which org's
definition a name means. It never sends it on a read, nor on star/unstar, recording an execution,
validate, render preview or the users batch: the registry treats a verified org header as a hard
scope on reads (and stars), so a client configured with an org could otherwise not read — or star —
another org's public definitions by name. Your own org's private definitions still appear in lists
and reads, because the registry checks your membership, not the header.
A bare name means different rows for reads and writes when another org published it first. A
read (and a star) of x means the org that first published x; a write of x means your
configured org's x. Until those coincide, read your org's copy through the exported
RegistryHttpClient with a per-call header (http.request('GET', path, undefined, { headers:
{ 'X-Org-Slug': 'my-org' } })). Qualified @org/name arguments to the RegistryClient methods
arrive with qualified addressing (spec I-4, Phase 3); passing one today raises a ValidationError
that says so. Through 0.56.x the header went on every request.
Create a .env file in your project:
ULUOPS_API_KEY=ulr_your-api-key-hereConstants
The /config sub-path exports constants for pre-flight checks, debugging, and defensive programming. For browser environments, use the /config/constants sub-path which has no Node.js dependencies:
import {
MAX_YAML_SIZE,
SDK_VERSION,
USER_AGENT,
HTTP_STATUS,
ERROR_CODES,
RETRYABLE_STATUS_CODES,
ENV_VARS,
CONFIG_PATHS,
DEFAULT_BASE_URL,
DEFAULT_TIMEOUT,
DEFAULT_RETRY_COUNT,
API_KEY_PREFIX,
} from '@uluops/registry-sdk/config';
// Validate payload size before uploading
if (yamlBuffer.byteLength > MAX_YAML_SIZE) {
throw new Error(`YAML exceeds ${MAX_YAML_SIZE} bytes`);
}
// Log the SDK version for debugging
console.log('SDK version:', SDK_VERSION);
// Check error codes programmatically
if (error.code === ERROR_CODES.NOT_FOUND) { /* ... */ }The sub-path also exports Node.js-only helpers for credential discovery:
import {
createClientFromEnvironment,
loadCredentials,
loadConfig,
loadStoredCredentials,
loadEnvFiles,
getGlobalConfigDir,
getCredentialsPath,
isApiKey,
validateCredentials,
} from '@uluops/registry-sdk/config';Error Handling
The SDK provides a typed error hierarchy so you can catch and recover from specific failure modes.
Error Classes
| Error | Status | When It Happens |
|-------|--------|-----------------|
| ValidationError | 400 | Malformed request — invalid params, missing fields |
| UnauthorizedError | 401 | No credentials, expired token, invalid API key |
| ForbiddenError | 403 | Valid credentials but insufficient permissions or subscription tier |
| NotFoundError | 404 | Definition, model, or user doesn't exist |
| ConflictError | 409 | Name collision, publishing already-published definition |
| PayloadTooLargeError | 413 | YAML exceeds 150KB limit |
| UnprocessableError | 422 | Valid YAML syntax but invalid semantics (missing refs, cycles) |
| RateLimitError | 429 | Too many requests (100 executions/min per definition) |
| ServiceUnavailableError | 502, 503, 504 | Server temporarily down or overloaded; keeps the server's details (since sdk-core 0.19.0) |
| NetworkError | - | DNS failure, connection refused, network unreachable (auto-retried) |
| TimeoutError | - | Request exceeded timeout (default: 30s) |
| ResponseValidationError | 0 | API response did not match the SDK's expected Zod schema (contract drift). Extends RegistryApiError; original ZodError preserved on .zodError. Non-retryable. |
The SDK pins an exact @uluops/sdk-core version (see package.json). HTTP 400, 404, 409 and 422 errors retain the
server's structured code and details, including upgrade refusal metadata.
isInvalidTransitionError(error) exposes details.allowedTransitions; isDeleteBlockedError(error) exposes blocker presence and a recovery action without revealing hidden references. A response-schema failure after a write may mean the write committed: read the current state before retrying.
All API errors extend RegistryApiError and include:
statusCode— HTTP status code (0 for network/timeout/response-validation)code— Machine-readable error code (e.g.,'NOT_FOUND','RATE_LIMIT_ERROR')message— Human-readable descriptiondetails— Optional structured metadatarequestId— Server request ID for support/debugging
Basic Error Handling
import {
RegistryApiError,
NotFoundError,
ValidationError,
isRegistryApiError,
} from '@uluops/registry-sdk/errors';
try {
const def = await client.definitions.get('agent', 'my-agent', '1.0.0');
} catch (error) {
if (error instanceof NotFoundError) {
console.log('Definition not found');
} else if (error instanceof ValidationError) {
console.log('Bad request:', error.details);
} else if (isRegistryApiError(error)) {
console.log(`API error [${error.code}]: ${error.message}`);
} else {
throw error; // Unexpected non-API error
}
}Lifecycle Recovery
Import the typed guards when a definition cannot move to the requested status or be deleted:
import { RegistryClient } from '@uluops/registry-sdk';
import { isInvalidTransitionError, isDeleteBlockedError } from '@uluops/registry-sdk/errors';
async function recoverLifecycle(client: RegistryClient): Promise<void> {
try {
await client.definitions.archive('agent', 'my-agent', '1.0.0');
} catch (error) {
if (!isInvalidTransitionError(error)) throw error;
console.log('Allowed next statuses:', error.details.allowedTransitions);
}
try {
await client.definitions.delete('agent', 'my-agent', '1.0.0');
} catch (error) {
if (!isDeleteBlockedError(error)) throw error;
console.log(error.details.recoveryAction);
console.log(error.details.blockingResources.present); // true; no hidden identities or counts
}
}Recovery Patterns
Handling Authentication Failures
import { UnauthorizedError, ForbiddenError } from '@uluops/registry-sdk/errors';
try {
await client.definitions.create('agent', 'my-agent', { yaml });
} catch (error) {
if (error instanceof UnauthorizedError) {
// Token expired or invalid — re-authenticate
const newToken = await refreshMyToken();
const retryClient = new RegistryClient({ sessionToken: newToken });
await retryClient.definitions.create('agent', 'my-agent', { yaml });
} else if (error instanceof ForbiddenError) {
// Valid auth but wrong role/tier. Since sdk-core 0.17.0 the API's
// structured denial survives into error.code / error.details — branch on
// it instead of message-matching:
if (error.code === 'TIER_REQUIRED') {
const details = error.details as { required?: string; upgradeUrl?: string } | undefined;
console.error(`Requires ${details?.required ?? 'a higher'} tier`, details?.upgradeUrl ?? '');
} else {
const details = error.details as { required_role?: string } | undefined;
console.error(`Requires role: ${details?.required_role ?? 'elevated permissions'}`);
}
}
}Rate Limit Backoff
The SDK auto-retries on 429 with exponential backoff, but if all retries are exhausted:
import { RateLimitError } from '@uluops/registry-sdk/errors';
try {
await client.executions.record('agent', 'my-agent', '1.0.0', { source: 'cli' });
} catch (error) {
if (error instanceof RateLimitError) {
const waitMs = (error.retryAfter ?? 60) * 1000;
console.log(`Rate limited. Waiting ${waitMs / 1000}s...`);
await new Promise((r) => setTimeout(r, waitMs));
await client.executions.record('agent', 'my-agent', '1.0.0', { source: 'cli' });
}
}Handling YAML Validation Errors
Two distinct failure modes for YAML — catch them separately:
import { ValidationError, UnprocessableError, PayloadTooLargeError } from '@uluops/registry-sdk/errors';
try {
await client.definitions.create('agent', 'my-agent', { yaml: rawYaml });
} catch (error) {
if (error instanceof PayloadTooLargeError) {
// YAML > 150KB — split or compress before retrying
console.error('YAML too large. Max: 150KB');
} else if (error instanceof ValidationError) {
// Malformed request (e.g., missing required fields in body)
console.error('Request validation failed:', error.details);
} else if (error instanceof UnprocessableError) {
// YAML parses but is semantically invalid (bad refs, missing interface, cycles)
console.error('YAML semantic errors:', error.details);
}
}