@uluops/ops-sdk
v6.14.0
Published
Track execution runs, manage issues, and query analytics against the UluOps platform API — typed TypeScript client with Zod runtime validation.
Maintainers
Readme
UluOps · The operations layer for agentic work
@uluops/ops-sdk
Official TypeScript SDK with Zod runtime validation for the UluOps platform API. Track execution runs, manage issues, analyze trends, and integrate agent pipelines into your workflow.
See the Changelog for the current version and release history.
Quick Start
Programmatic Usage
NODE_ENV=development selects http://localhost:3100/api/v1. Start that API
server or pass baseUrl before making a request; otherwise a connection failure
is expected. A client without credentials can call public endpoints, while run
writes and other authenticated operations require an API key or a logged-in session.
import { OpsClient } from '@uluops/ops-sdk';
// Auto-loads credentials from ULUOPS_API_KEY env var, .env file, or ~/.uluops/credentials.json
const client = new OpsClient();
// Or pass an API key explicitly
// const client = new OpsClient({ apiKey: 'ulr_your-api-key-here' });
// Save an execution run
const result = await client.runs.save({
project: 'my-project',
workflowType: 'post-implementation',
agents: [
{ name: 'code-validator', score: 85, decision: 'PASS' },
{ name: 'test-architect', score: 72, decision: 'APPROVED' },
],
recommendations: [
{
agent: 'code-validator',
title: 'Missing error handling',
priority: 'suggested',
filePath: 'src/api/client.ts',
lineNumber: 42,
},
],
});
// `correlation` is null on an idempotent replay of a run saved before correlation persistence
console.log(`Run #${result.run.runNumber} saved: ${result.correlation?.newIssues ?? 'n/a'} new issues`);Search Issues
const issues = await client.issues.search({
query: 'authentication',
status: 'open',
priority: 'critical',
});
for (const issue of issues) {
console.log(`[${issue.severity}] ${issue.title} — ${issue.filePath}:${issue.lineNumber}`);
}Project Analytics
const burndown = await client.analytics.getBurndown({
project: 'my-project',
days: 30,
});
for (const [domain, trend] of Object.entries(burndown.trends)) {
console.log(`${domain}: ${trend.trend} (avg daily change: ${trend.avgDailyChange})`);
}Table of Contents
- Overview
- Features
- Installation
- Authentication
- TypeScript Support
- API Reference
- Environment Variables
- Error Handling
- Advanced Usage
- CLI
- Input Validation
- Troubleshooting
- License
Overview
The UluOps SDK provides programmatic access to the UluOps platform API, enabling you to:
- Track Execution Runs: Save agent, workflow, and pipeline results with scores, recommendations, and analysis
- Manage Issues: Create, search, update, and track issues across projects
- Analyze Trends: Get burndown charts, velocity metrics, and taxonomy distribution analytics
- Automate Workflows: Integrate execution tracking into CI/CD and agent pipelines
The SDK covers the full platform API surface across 8 operation domains with full TypeScript support.
Features
- Full API Coverage: auth, projects, runs, issues, analytics, taxonomy, orgs, and admin domains
- Type-Safe: Complete TypeScript definitions; every response is Zod-parsed before it is returned. Write inputs are Zod-validated as a gate (the raw input is what goes to the wire, and the primary writers may pass
_skipClientValidation) — see Runs Operations for which fields each write validates - 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
- Subpath Exports: Import only what you need (
@uluops/ops-sdk/types,@uluops/ops-sdk/errors)
Installation
# npm
npm install @uluops/ops-sdk
# yarn
yarn add @uluops/ops-sdk
# pnpm
pnpm add @uluops/ops-sdk
# bun
bun add @uluops/ops-sdkRequirements:
- Node.js 20.3.0 or higher (enforced by
enginesinpackage.json) - TypeScript 5.0+ (for TypeScript users)
Dependencies:
@uluops/sdk-core— Shared HTTP client, auth strategies, and utilities (installed automatically)zod— Runtime schema validation (installed automatically)
Authentication
The SDK supports two authentication methods. To get an API key, visit the UluOps Dashboard or create one programmatically via client.auth.createApiKey().
API Key Authentication (Recommended)
API keys provide persistent authentication without session management. Keys must start with the ulr_ prefix.
import { OpsClient } from '@uluops/ops-sdk';
const client = new OpsClient({
apiKey: 'ulr_your-api-key-here',
});
// Check authentication status
console.log(client.isAuthenticated()); // true
console.log(client.getAuthType()); // 'api_key'Session-Based Authentication
For interactive applications, use client.login() which installs the session and, by default, re-logs in on a 401:
import { OpsClient } from '@uluops/ops-sdk';
const client = new OpsClient();
// Login — installs session auth with automatic token refresh
const { sessionToken, user } = await client.login(
'[email protected]',
'your-password',
);
// Client is now authenticated — subsequent requests use the session token
const { data: projects } = await client.projects.list();
// Logout when done
await client.logout();Note: Prefer
client.login()overclient.auth.login(). The latter only returns the token without installing it, requiring manual client construction.
Accounts with MFA (6.4.0). For an account with TOTP or a passkey enrolled, POST /auth/login
answers a challenge, not a session, and client.login() throws MfaRequiredError carrying
mfaChallengeToken, expiresAt and mfaMethods. Complete it with the current code:
import { isMfaRequiredError } from '@uluops/ops-sdk';
try {
await client.login(email, password);
} catch (err) {
if (!isMfaRequiredError(err)) throw err;
await client.loginWithTotp(err.mfaChallengeToken, '123456'); // installs the session
}The challenge token is single-use and is consumed before the code is checked — a mistyped
code burns it; do not loop on loginWithTotp with the same token, call login() again for a fresh
challenge. A TOTP-installed session has no password to re-login with, so it is not
auto-refreshed: when it expires, requests fail 401 and you log in again. (Before 6.4.0 an MFA
account could not log in through login() at all — the challenge body failed the session schema
with a ZodError.) MfaRequiredError is raised by login() / auth.login() only: a client
constructed with { email, password } (or autoloaded credentials) logs in inside sdk-core, which
cannot see the challenge and surfaces a generic UnauthorizedError — MFA accounts must use the
two-step path. WebAuthn completion is not offered here.
What "auto-refresh" does — read before scripting against a session. With the default
login(), the password is kept so sdk-core can re-login when a request answers 401. That refresh
is a fresh login: under the API's default single-session policy it revokes the user's other
sessions (your dashboard tab); mutations are not retried afterwards (the POST that hit the 401
still throws it); and the budget is one (the password is cleared after the first re-login).
For a script — the Phase 4 migration, anything that shares the account with a dashboard — pass
{ autoRefresh: false } so a 401 means stop, untouched:
await client.login(email, password, { autoRefresh: false }); // session without a password → no re-loginCredential Priority Chain
The SDK loads credentials in the following order:
- Constructor arguments:
apiKey,sessionToken,email/password - Environment variables:
ULUOPS_API_KEY,ULUOPS_EMAIL,ULUOPS_PASSWORD - Local
.envfile: In the current working directory - Global credentials:
~/.uluops/credentials.json
TypeScript Support
The SDK is written in TypeScript with full type definitions. Import types directly:
// Main client
import { OpsClient, type OpsClientConfig } from '@uluops/ops-sdk';
// Types only
import type {
Project,
Issue,
Run,
AgentPerformance,
Priority,
Status,
Severity,
// Issue history envelope (added in 3.2.0 — see CHANGELOG)
IssueHistoryEnvelope,
HistoryEvent,
HistoryOccurrenceEvent,
HistoryStatusEvent,
HistoryNoteEvent,
TransitionType,
} from '@uluops/ops-sdk/types';
// Errors only
import {
OpsApiError,
ValidationError,
NotFoundError,
RateLimitError,
} from '@uluops/ops-sdk/errors';
// Config utilities (also re-exported from the package root)
import {
loadCredentials, // resolve credentials from options > env > credentials.json
loadConfig, // resolve full config (credentials + connection settings)
loadEnvFiles, // load .env files into process.env before reading config
DEFAULT_BASE_URL, // default API base URL
ENV_VARS, // map of the env var names the SDK reads
API_KEY_PREFIX, // expected API key prefix ('ulr_')
} from '@uluops/ops-sdk/config';Package Exports
| Export Path | Contents |
|------------|----------|
| @uluops/ops-sdk | Main OpsClient, OpsHttpClient, auth strategies, config helpers, all types |
| @uluops/ops-sdk/types | All TypeScript types and Zod input schemas |
| @uluops/ops-sdk/types/projects | Project types only |
| @uluops/ops-sdk/types/issues | Issue types only |
| @uluops/ops-sdk/types/runs | Run types only |
| @uluops/ops-sdk/types/analytics | Analytics types only |
| @uluops/ops-sdk/types/enums | Priority, Status, Severity enums + failure-code helpers (parseFailureCode, buildFailureCode, severityFromCode) |
| @uluops/ops-sdk/types/responses | API response types |
| @uluops/ops-sdk/types/schemas | Zod input validation schemas |
| @uluops/ops-sdk/types/auth | Auth/credential types |
| @uluops/ops-sdk/errors | Error classes and utilities |
| @uluops/ops-sdk/config | Configuration loaders, constants, and input validators |
Granular Type Imports
For minimal bundle size, import only the type modules you need:
import type { Project } from '@uluops/ops-sdk/types/projects';
import type { Issue } from '@uluops/ops-sdk/types/issues';
import type { Run, SaveRunInput } from '@uluops/ops-sdk/types/runs';
import type { BurndownResult } from '@uluops/ops-sdk/types/analytics';
import type { Priority, Status, Severity } from '@uluops/ops-sdk/types/enums';
import type { ApiResponse } from '@uluops/ops-sdk/types/responses';
import type { Credentials } from '@uluops/ops-sdk/config';Failure Code Utilities
The types/enums subpath also ships runtime helpers for working with failure codes (the DOMAIN-MODE/SEVERITY taxonomy on recommendations):
import { parseFailureCode, buildFailureCode, severityFromCode } from '@uluops/ops-sdk/types/enums';
parseFailureCode('SEM-INC/H'); // { domain: 'SEM', mode: 'INC', severityCode: 'H' } — or null if malformed
buildFailureCode('SEM', 'INC', 'H'); // 'SEM-INC/H'
severityFromCode('H'); // 'high' — or null if the code is unknownAPI Reference
Client Configuration
const client = new OpsClient({
// Authentication (choose one)
apiKey: 'ulr_...', // API key (preferred)
sessionToken: 'jwt-token', // Existing session token
email: '[email protected]', // Email for login
password: 'password', // Password for login
// Connection settings (baseUrl defaults to https://api.uluops.ai/api/v1)
timeout: 30000, // Request timeout in ms (default: 30000)
retries: 3, // Retry count for transient errors (default: 3)
debug: false, // Enable debug logging
// Multi-tenancy
orgSlug: 'my-org', // Org slug (sets X-Org-Slug header on all requests);
// any method's trailing `{ org }` overrides it per call
// Callbacks
onTokenRefresh: (token) => { /* handle token refresh */ },
onRateLimitApproaching: (info) => {
console.warn(`Rate limit: ${info.remaining}/${info.limit} remaining, resets ${info.reset}`);
},
onRetry: ({ attempt, maxAttempts, error, delayMs }) => {
console.warn(`Retry ${attempt}/${maxAttempts} after ${delayMs}ms: ${error.message}`);
},
onSecurityEvent: (event) => { /* route to telemetry — see "Security Events" */ },
});Org routing — which org a call lands in
Every project, run, issue and analytics method takes a trailing options with org?: string
(run writes: RunCallOptions, which also carries _skipClientValidation). It becomes the
X-Org-Slug header on that one request.
await client.runs.save(input, { org: 'ulu-labs' }); // this call → ulu-labs
await client.projects.list({ org: 'ulu-labs' }); // reads take it too
await client.projects.list(); // → constructor orgSlug, else your personal orgPrecedence on the wire, lowest to highest: personal org (no header) < constructor orgSlug
< per-call org. An API key bound to an org ignores both headers and answers
403 ORG_ACCESS_DENIED if they name a different org. The API never infers an org from a project
name: a call that names no org creates or targets the personal project of that name, even when
a work org has one by the same name (spec D2). Name the org.
These org-routing errors are worth branching on (all exported with type guards):
| Code | Status | Guard | What to do |
|---|---|---|---|
| INSUFFICIENT_ORG_ROLE | 403 | isInsufficientOrgRoleError | Your role in that org is below publisher. Do not retry without org — an org-less retry files the work in your personal org; the API says so in the body (details.applied: false). |
| ORG_ACCESS_DENIED | 403 | isOrgAccessDeniedError | Not a member of that org, or your key is bound to a different one. Terminal. |
| PROJECT_REHOMED | 410 | isProjectRehomedError | The project moved orgs. err.details.target_org.slug is where it lives — pass it as org and retry the same call. |
| SESSION_REQUIRED | 403 | isSessionRequiredError | A session-only admin route refused an API key (D20). Log in; never mint another key to get past it. |
| INSUFFICIENT_ROLE | 403 | isInsufficientRoleError | The admin path needs the platform role (users.role = admin); no org argument changes it. Terminal. |
Low-level: new OpsHttpClient(cfg).withOrg('acme') returns a view of the client scoped to that org.
Where does the default come from? For tools that run inside a checkout (the CLI, the tracker
MCP), resolveWorkspaceOrg({ explicit, cwd }) implements the spec's D13 rule: an explicit value
wins; else the nearest .uluops.json above cwd ({ "org": "ulu-labs" }, or { "org": "personal" }
to stop the walk in a personal repo nested under a work tree); else ULUOPS_ORG_SLUG; else your
personal org. The file may carry only org, project and $schema — any other key is refused, not ignored.
project (6.5.0, ulu log D5) is the project name a read command resolves when given none —
ulu log today; it governs reads only (no write path consumes it; ulu exec keeps flag → env →
inferred basename). Read it with readWorkspaceFile(path) → { org?, project? }; a file carrying
project must carry org ("personal" allowed) or the reader throws, so a project-only file can
never shadow an outer org. readWorkspaceOrgFile(path) keeps its signature and now returns the org
of such a file instead of refusing it. Do not write project into a checkout until every installed
@uluops/ops-sdk in that tree is ≥ 6.5.0 — older readers throw on the key, for every command.
Two more refusals since 6.3.1 (security audit run #187): the walk never rises above your home
directory (a file at / or /Users cannot become everyone's default), and a file owned by another
user is refused (a shared parent directory is the planting vector). "personal" is also honoured as
an explicit value — { org: 'personal' } sends no header. On a scoped call the per-call org header
is set last and an X-Org-Slug/X-Org-Id in options.headers is refused.
The pieces it is built from are exported too: findWorkspaceOrgFile(cwd) (the bounded upward walk, returning the path of the .uluops.json that answered), WORKSPACE_ORG_FILE (the file name, .uluops.json) and PERSONAL_ORG_SENTINEL ("personal", the value that stops the walk and means "no org"). Reach for them when a tool wants to report which file answered, as the CLI does.
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, instead of scraping logs or classifying thrown errors. 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 { OpsClient, type SecurityEvent } from '@uluops/ops-sdk';
const client = new OpsClient({
apiKey: 'ulr_...',
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 |
Related: a blocked redirect throws RedirectError (re-exported, non-retryable — catch it with isRedirectError(e) where you previously caught NetworkError for a redirect). The SecurityEvent union and its member types are exported from the package root.
Auth Operations
Manage user authentication, API keys, and sessions.
client.auth.register(input)
Register a new user account.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| email | string | Yes | User email address |
| password | string | Yes | User password |
const { user, token } = await client.auth.register({
email: '[email protected]',
password: 'securePassword123',
});client.auth.login(input)
Login with email and password.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| email | string | Yes | User email address |
| password | string | Yes | User password |
const { user, sessionToken } = await client.auth.login({
email: '[email protected]',
password: 'password123',
});client.auth.totpLogin(input)
Complete an MFA challenge with a TOTP code. Returns the session without installing it on the
client — use the top-level client.loginWithTotp() wrapper when you want the
session installed. The challenge token is single-use and is consumed before the code is checked.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| mfaChallengeToken | string | Yes | From the MfaRequiredError thrown by login() |
| code | string | Yes | Six-digit TOTP code |
| rememberMe | boolean | No | Long-lived session |
const { sessionToken, expiresAt } = await client.auth.totpLogin({
mfaChallengeToken: err.mfaChallengeToken,
code: '123456',
});client.auth.logoutAll()
Revoke all active sessions for the current user.
const { sessionsRevoked } = await client.auth.logoutAll();
console.log(`Revoked ${sessionsRevoked} sessions`);client.auth.getMe()
Get the current authenticated user, plus the account facts /auth/me adds (6.12.0+: these were silently stripped before):
const user = await client.auth.getMe();
console.log(user.email, user.role);
user.mfaEnabled; // any VERIFIED second factor (totpEnabled || webauthnEnabled)
user.totpPending; // TOTP secret stored but not verified, so not active
user.personalOrgSlug; // null if noneAll of these are optional, so an older API may omit them.
client.auth.getProfile()
Get detailed user profile.
const { user } = await client.auth.getProfile();client.auth.updateProfile(input)
Update user profile information.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| username | string | No | Username (lowercase, 3-30 chars) |
| name | string | No | Display name |
| bio | string | No | User bio |
| avatar | string | No | Avatar image (base64 encoded) |
| avatarMimeType | string | No | Avatar MIME type (e.g., image/png) |
Note: At least one field must be provided.
const { user } = await client.auth.updateProfile({
name: 'John Doe',
bio: 'Software Engineer',
});client.auth.changePassword(input)
Change the current user's password.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| currentPassword | string | Yes | Current password |
| newPassword | string | Yes | New password |
await client.auth.changePassword({
currentPassword: 'oldPassword',
newPassword: 'newSecurePassword',
});client.auth.setPassword(password)
Set password for accounts created without one (e.g., OAuth or admin-created).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| password | string | Yes | New password |
await client.auth.setPassword('newSecurePassword');client.auth.forgotPassword(email)
Request a password reset email.
await client.auth.forgotPassword('[email protected]');client.auth.resetPassword(input)
Reset password using a reset token.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| token | string | Yes | Reset token from email |
| password | string | Yes | New password |
await client.auth.resetPassword({
token: 'reset-token-from-email',
password: 'newSecurePassword',
});client.auth.getAvatar()
Get the current user's avatar as binary data.
const { data, contentType } = await client.auth.getAvatar();
// data: ArrayBuffer, contentType: e.g. 'image/png'client.auth.deleteAvatar()
Delete the current user's avatar.
await client.auth.deleteAvatar();client.auth.listApiKeys()
List all API keys for the current user.
const keys = await client.auth.listApiKeys();
for (const key of keys) {
console.log(key.name, key.prefix, key.createdAt);
}client.auth.createApiKey(input)
Create a new API key.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| name | string | No | Key name/description |
| scope | 'read' \| 'write' | No | Per-key scope (v5.21.0 / platform v1.27.0). Omitted ⇒ 'write' server-side. A read key gets 403 INSUFFICIENT_SCOPE on any non-GET/HEAD request. |
// A read-only key — cannot write (close/delete/merge), only read
const { id, name, key } = await client.auth.createApiKey({ name: 'CI Pipeline', scope: 'read' });
console.log('Save this key:', key); // Only shown once!
// Listed keys carry their scope:
const keys = await client.auth.listApiKeys();
keys.forEach((k) => console.log(k.name, k.scope)); // 'CI Pipeline' 'read'client.auth.revokeApiKey(keyId)
Revoke an API key.
await client.auth.revokeApiKey('key-id-123');client.auth.listSessions()
List all active sessions.
const sessions = await client.auth.listSessions();
for (const session of sessions) {
console.log(session.userAgent, session.createdAt);
}client.auth.revokeSession(sessionId)
Revoke a specific session.
await client.auth.revokeSession('session-id-123');Project Operations
Manage projects.
client.projects.list()
List all projects.
const { data: projects, total } = await client.projects.list();
for (const project of projects) {
console.log(project.id, project.name, project.createdAt);
}client.projects.get(idOrName)
Get a project by ID or name.
const project = await client.projects.get('my-project');
console.log(project.name, project.runCount, project.issueCount);client.projects.create(input)
Create a new project.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| name | string | Yes | Project name (unique) |
const project = await client.projects.create({ name: 'new-project' });client.projects.update(idOrName, input)
Update a project.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| name | string | No | New project name |
const project = await client.projects.update('my-project', {
name: 'renamed-project',
});client.projects.rename(input)
Rename a project.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| oldName | string | Yes | Current project name |
| newName | string | Yes | New project name |
const project = await client.projects.rename({
oldName: 'old-name',
newName: 'new-name',
});client.projects.delete(idOrName, input)
Permanently delete a project.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| confirm | boolean | Yes | Must be true |
| confirmationPhrase | string | Yes | Must match project name |
await client.projects.delete('my-project', {
confirm: true,
confirmationPhrase: 'my-project',
});client.projects.softDelete(idOrName, input)
Soft delete a project (can be restored).
await client.projects.softDelete('my-project', {
confirm: true,
confirmationPhrase: 'my-project',
});client.projects.restore(idOrName)
Restore a soft-deleted project.
const project = await client.projects.restore('my-project');client.projects.getSummary(idOrName)
Get project summary with statistics.
const summary = await client.projects.getSummary('my-project');
console.log(`Total runs: ${summary.totalRuns}`);
console.log(`Total issues: ${summary.totalIssues}`);
console.log(`Open issues: ${summary.openIssues}`);client.projects.getTrends(idOrName, query)
Get issue trend data over time.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| days | number | No | Number of days (default: 30) |
const trends = await client.projects.getTrends('my-project', { days: 30 });
for (const point of trends) {
console.log(point.date, point.openIssues, point.closedIssues);
}client.projects.getLog(idOrName, query?, options?) — the project log (ulu log §3.2)
The project's second history: run events (what was examined) and decision / regression
events (what was decided, with reasons — and what came back) interleaved newest first,
keyset-paged. Pass nextCursor back verbatim. Three things to keep straight when rendering:
reason: null is no reason recorded; source: null is unattributed, never human; a
regression is a row a run re-detected, while a resolved → open decision with no run is
reopened by decision (D12) — the SDK types both and does not collapse them. counts is null
on runs saved before migration 065.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| since / until | string | No | ISO 8601 window; since <= until or 400 |
| limit | number | No | 1–500, default 50; outside the range is a 400 |
| cursor | string | No | A nextCursor from the previous page, verbatim |
| kind | ('run'\|'decision'\|'regression')[] | No | Subset of event kinds |
| workflowType | string | No | Filters run events only |
| agent | string | No | Runs by snapshot agent name; ledger rows by issues.agent |
| includeArchived | boolean | No | Archived runs are excluded unless true |
Query keys go to the wire as named (workflowType, not workflow_type): the API's schema is
camelCase and silently ignores a snake_cased key, so this call does not use the SDK's generic
snake_casing.
let cursor: string | undefined;
do {
const page = await client.projects.getLog('my-project', { limit: 100, cursor }, { org: 'ulu-labs' });
for (const e of page.data) {
if (e.type === 'run') console.log(e.at, `run #${e.runNumber}`, e.workflowType, e.counts ?? '-');
else if (e.type === 'decision') console.log(e.at, e.fingerprint, `${e.from} -> ${e.to}`, e.reason ?? 'no reason recorded');
else console.log(e.at, e.fingerprint, 'regressed', e.viaRunNumber === null ? 'via run ?' : `via run #${e.viaRunNumber}`);
}
cursor = page.hasMore ? page.nextCursor : undefined;
} while (cursor);client.projects.getLogStat(idOrName, query?, options?) — the rollup (§3.3)
{ projectId, window, examined, found, decided, cameBack, activity }. Two frames on two clocks:
the cohort frame (examined, found, decided) windows on run timestamps; the activity
frame (activity, cameBack) on ledger timestamps. decided is the current status of each
found issue and sums to found.issues — it is not "decisions made in the window"; that is
activity.decisions. activity.byStatus.open counts transitions into open (render it
reopened). cameBack.detected / reopened are distinct issues with the row counts beside
them; lastDetectedAtAllTime ignores the window by definition. since / until as above.
const stat = await client.projects.getLogStat('my-project');
console.log(`${stat.examined.runs} runs, ${stat.found.issues} findings, ${stat.decided.completed} fixed, ${stat.cameBack.detected} regressions caught by re-running`);client.projects.listIssues(idOrName, query)
List issues for a project with filters.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| status | Status | No | Filter by status |
| priority | Priority | No | Filter by priority |
| severity | Severity | No | Filter by severity |
| failureDomain | FailureDomain | No | Filter by taxonomy domain (STR/SEM/PRA/EPI) |
| failureMode | string | No | Filter by taxonomy mode — the MODE half of a DOMAIN-MODE/SEVERITY code, e.g. OMI. Three uppercase letters. Intentionally not restricted to the canonical mode set, so non-canonical rows remain findable. Requires API ≥ the release carrying the mode-filter fix; against an older API this filter is silently ignored and you receive unfiltered results. |
| agent | string | No | Filter by agent |
| includeResolved | boolean | No | Include completed/wontfix/false-positive issues |
| minTimesSeen | number | No | Only issues seen at least this many times |
| dateStart | string | No | ISO 8601 — issues created on or after |
| dateEnd | string | No | ISO 8601 — issues created on or before |
| limit | number | No | Max results (default: 50) |
| offset | number | No | Pagination offset |
Filter convention: Passing
'all'for any filter (e.g.,status: 'all') is equivalent to omitting the parameter — the SDK strips'all'values before sending the request. This applies to all query methods across the SDK.The table above is the canonical filter set for
projects.listIssuesandissues.listByProject— both take the same query shape.issues.searchis the exception: it acceptsfailureDomains(an array) and does not acceptfailureModeat all, because the server-side search path has no mode predicate. If you need to filter by mode, use one of the two list methods.
// {data, total} since 6.0.0 — `total` is the full matching count, for pagination
const { data: issues, total } = await client.projects.listIssues('my-project', {
status: 'open',
priority: 'critical',
limit: 10,
});
console.log(`Showing ${issues.length} of ${total} open critical issues`);client.projects.bulkUpdateIssueStatus(idOrName, updates)
Bulk update issue statuses.
const results = await client.projects.bulkUpdateIssueStatus('my-project', [
{ issueId: 'issue-1', status: 'completed', reason: 'Fixed' },
{ issueId: 'issue-2', status: 'deferred', reason: 'Not a priority' },
]);client.projects.mergeIssues(idOrName, input)
Merge duplicate issues.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| targetIssueId | string | Yes | Issue to merge into |
| sourceIssueIds | string[] | Yes | Issues to merge from |
| strategy | string | No | 'keep_target' or 'keep_highest_priority' |
const result = await client.projects.mergeIssues('my-project', {
targetIssueId: 'issue-1',
sourceIssueIds: ['issue-2', 'issue-3'],
strategy: 'keep_target',
});client.projects.mergeProjects(input)
Merge one project into another (merge-projects spec v0.3.4). The source's runs and issues are re-keyed into the target inside one advisory-locked transaction; the source is soft-deleted by default. Pairwise only — chain calls for multi-source merges. Dry-run first.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| source | string | Yes | Source project name or UUID (consumed by the merge) |
| target | string | Yes | Target project name or UUID (survives, absorbs the source) |
| dryRun | boolean | No | Preview only — the merge transaction is rolled back (default false) |
| deleteSource | boolean | No | Soft-delete the source after the merge (default true) |
| confirmCrossOrg | boolean | No | Required true for system-actor cross-org merges; human cross-org merges are always rejected |
const preview = await client.projects.mergeProjects({
source: 'old-project',
target: 'my-project',
dryRun: true,
});
console.log(`Would move ${preview.moved.runs} runs and ${preview.moved.issues} issues`);
if (preview.conflicts.length === 0) {
const result = await client.projects.mergeProjects({ source: 'old-project', target: 'my-project' });
console.log(result.source.statusAfter); // 'soft-deleted'
}Returns { source, target, moved, conflicts } — source.statusAfter is 'soft-deleted' | 'retained' | 'dry-run', moved counts runs, issues, dedupes and reparented occurrences/notes/history.
client.projects.rehome(idOrName, input, options?) — move a project to another org
Moves the project and its whole history (runs, issues, analytics) into another org
(project-org-routing-and-rehome spec §4.1, D14). The source org is this call's org scope —
pass { org: '<source>' } (or set the client orgSlug) unless the project is in your personal
org; the project is looked up there, and an unscoped call for a work-org project is a 404. You
need admin/owner in both orgs; a personal org as target only when it is yours.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| targetOrg | string | Yes | Slug of the destination org |
| reason | string | No | ≤ 500 chars; stored on the tombstone, never rendered into an error |
const moved = await client.projects.rehome('billing', { targetOrg: 'ulu-labs', reason: 'team took it over' }, { org: 'acme' });
moved.orgId; // the target org's id
moved.rehome.from_org; // { id, slug: 'acme' }
moved.rehome.to_org; // { id, slug: 'ulu-labs' }
moved.rehome.audit_ids; // [] today — the ledger, not this array, is the durable recordAfter the move the old (org, name) address is a tombstone: an org-less write naming the
project there (a save or a create) gets 410 PROJECT_REHOMED naming the target
(isProjectRehomedError) instead of silently forking a new project. Reads at the old address 404.
Moving back is an ordinary rehome the other way and annihilates the tombstone.
Refusals a caller branches on — rehomeRefusalReason(err) reads the enumerated
details.reason values and returns null for anything else:
| Answer | Meaning | Disposition |
|---|---|---|
| 400 same_org | already there | done (idempotent re-run) |
| 409 moved_during_request / deadlock_retry / concurrent_modification | lost a race | re-read, retry once |
| 409 name_collision / soft_deleted_conflict / rehomed_away_conflict | the name is taken in the target (live, soft-deleted, or reserved by another move) | skip and report |
| 409 export_in_progress | an export holds one of the orgs | wait, retry |
| 409 project_soft_deleted | the project is soft-deleted in the source | restore it there first, then move |
| 400 project_has_no_org | pre-org legacy row | stop; an operator repairs the row |
| 403 INSUFFICIENT_ORG_ROLE / ORG_ACCESS_DENIED / ORG_SUSPENDED | authority (source role, target membership or a personal target, C3) | stop |
| 402 PROJECT_LIMIT | target org at its project cap | stop |
| no HTTP answer (isTimeoutError / isNetworkError; rehomeRefusalReason → null) | the server may have committed the move before the response was lost | do not retry blind — read the project with { org: targetOrg } (or the admin ledger); on the member path a blind retry is a 404, not same_org |
Re-runs. same_org is the idempotence signal of the admin path (lookup by id, unscoped).
On the member path the lookup is source-scoped, so the same call after the move answers 404
(the source address is a tombstone) — not same_org. A member-path script that wants "already
done" checks the target org, not the refusal code.
Run Operations
Save and manage execution runs.
client.runs.save(input, options?)
Save a new execution run. Pass { _skipClientValidation: true } as the second argument to bypass client-side Zod validation (useful when input is already validated by an upstream layer like MCP).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| project | string | Yes | Project name or ID |
| workflowType | string | Yes | Workflow type (e.g., 'post-implementation') |
| agents | AgentInput[] | Yes | Array of agent results |
| recommendations | Recommendation[] | Yes | Array of issues/recommendations (use [] for empty). Multi-agent pipelines: see Convergence clustering before collapsing findings |
| summary | object | No | Summary statistics |
| rawMarkdown | string | No | Raw markdown report |
| idempotencyKey | string | No | Key for duplicate prevention. When omitted, the SDK derives it from the payload content (sha256), so a byte-identical retry returns the original run (deduplicated: true) instead of creating a second one. The hash is over JSON.stringify of the payload, so it is key-order-sensitive inside caller-supplied objects (agents[], recommendations[], analysisRecords[].data): a retry that rebuilds those with a different insertion order is a different key and writes a second run. Pass an explicit key when the retry path does not preserve object shape; pass explicit distinct keys to deliberately save identical payloads twice |
| definitionType | string | No | Definition type ('agent', 'command', 'workflow', 'pipeline') |
| definitionName | string | No | Definition name (e.g., 'code-validator') |
| definitionVersion | string | No | Definition version (e.g., '1.2.0') |
| definitionHash | string | No | SHA-256 content hash of the definition |
| definitionId | string | No | Registry definition UUID for direct identity linkage |
| timestamp | string | No | ISO 8601 timestamp override (defaults to server time) |
| definitionMinSubscription | SubscriptionTier | No | Minimum subscription tier required for this definition |
| analysisRecords | AnalysisRecordInput[] | No | Structured analysis records (v1.4.0) |
| analysisSummary | AnalysisSummaryInput \| AnalysisSummaryInput[] | No | Single or per-agent array of analysis summaries (v1.8.1) |
| analysisSummary.explorationMaps | ExplorationMap[] | No | Structural maps from explorer agents (v1.8.0) |
Convergence clustering (clusterKey)
(v5.12.0) If your pipeline has a stage that adjudicates duplication — a merge,
falsification or synthesis stage that decides several agents reported one defect —
submit one recommendation per agent and tag them with a shared clusterKey, rather
than collapsing them into a single row before submission.
recommendations: [
{ agent: 'security-analyst', title: 'Unbounded query in the export path', priority: 'critical', clusterKey: 'cluster-alpha' },
{ agent: 'code-auditor', title: 'Unbounded query in the export path', priority: 'critical', clusterKey: 'cluster-alpha' },
]- Within-run only. Two recommendations sharing a
clusterKeyare the same defect in the same run. The value carries no meaning across runs and is never joined across them. - Opaque. The tracker stores it verbatim and never interprets or verifies it. Any stable string up to 64 chars works; a longer one is rejected rather than truncated, because a truncated key is a different cluster id that still looks valid.
- Omit it if your pipeline has no adjudicating stage. That is the normal case and is not a defect. Absent means "no stage declared", which the tracker distinguishes from a stage that has stopped clustering.
- Requires a tracker with migration 076. Against an older one the field is discarded.
Collapsing before submission is what this replaces: the merge result reaches the tracker as single-agent rows and the fact that n agents converged is lost.
const result = await client.runs.save({
project: 'my-project',
workflowType: 'post-implementation',
agents: [
{
name: 'code-validator',
score: 85,
decision: 'PASS',
summary: 'Code quality is strong — minor naming inconsistencies in utils/',
model: 'sonnet',
harness: 'claude-code', // producing CLI/runtime (v5.2.0) — free string; claude-code | codex | opencode | gemini-cli | uluops-core
tokens: {
inputTokens: 1000,
outputTokens: 500,
// Cross-harness components (v5.2.0, all optional). cachedInput is subtracted in
// total_effective; reasoning/thinking/tool are subsets of gross output, never added.
cachedInputTokens: 200,
reasoningOutputTokens: 0,
},
},
{
name: 'test-architect',
score: 72,
decision: 'APPROVED',
summary: 'Good coverage overall but edge cases missing in auth module',
},
],
recommendations: [
{
agent: 'code-validator',
title: 'Missing error handling in API client',
priority: 'suggested',
severity: 'medium',
filePath: 'src/api/client.ts',
lineNumber: 42,
description: 'Add try-catch for network errors',
failureCode: 'PRA-FRA/M',
},
],
summary: {
averageScore: 78.5,
allGatesPassed: true,
},
});
console.log(`Run #${result.run.runNumber} saved`);
console.log(`Issues: ${result.correlation?.newIssues} new, ${result.correlation?.recurringIssues} recurring`);
result.correlationisnullonly when an idempotent replay returns a run that was saved before the API began persisting correlation counts — those counts were never stored and are not fabricated. Fresh saves and post-migration replays always carry a correlation object.
client.runs.validate(input, options?)
Preview what a save would do without persisting. Accepts same { _skipClientValidation: true } option as save(). Since v3.2.2, also previews analysisRecords and analysisSummary persistence so the dry-run reflects the full set of side effects save() would produce (requires API v1.58.1+).
const preview = await client.runs.validate({
project: 'my-project',
workflowType: 'post-implementation',
agents: [{ name: 'code-validator', score: 90, decision: 'PASS' }],
recommendations: [{ agent: 'code-validator', title: 'Unused import', priority: 'backlog', failureCode: 'STR-OMI/L' }],
// Optional — same shape save() accepts
analysisRecords: [
{ recordId: 'C-1', recordType: 'convergence', title: 'Lens convergence', data: { evidence: ['src/foo.ts:42'] } },
],
analysisSummary: { decision: 'PASS', score: 88 },
});
console.log('Would create issues:', preview.wouldCreate);
console.log('Would update issues:', preview.wouldUpdate);
console.log('Would regress issues:', preview.wouldRegress);
console.log('Would observe issues:', preview.wouldObserve);
// v3.2.2+: optional analysis previews (present when API >= v1.58.1)
console.log('Would create analysis records:', preview.wouldCreateAnalysisRecords);
console.log('Would create analysis summaries:', preview.wouldCreateAnalysisSummaries);
console.log('Analysis records to be created:', preview.preview.analysisRecords);
allGatesPassedisboolean | nullon all run responses (since v5.10.0).nullmeans NOT_A_GATE — the run carried no gate-bearing agents (e.g. a cognitive-lens-only run), which is distinct fromfalse(a gate ran and failed). Rendernullas "N/A"/"—", and exclude null runs from pass-rate denominators. The input fieldsummary.allGatesPassedis unchanged (boolean | undefined;nullis never a valid input). The API begins emittingnullonly after its consumers resolve v5.10.0+ — older SDK versions throwZodErroron a null-gate response.
client.runs.listByProject(projectId, query)
List runs for a project.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| workflowType | string | No | Filter by workflow type |
| limit | number | No | Max results |
| offset | number | No | Pagination offset |
// {data, total} since 6.0.0
const { data: runs, total } = await client.runs.listByProject('my-project', {
workflowType: 'ship',
limit: 10,
});client.runs.getLatest(projectId, workflowType)
Get the latest run for a project.
const latestRun = await client.runs.getLatest('my-project', 'post-implementation');client.runs.get(runId)
Get a run by ID.
const run = await client.runs.get('run-uuid-here');client.runs.getDetails(projectId, runNumber)
Get detailed run information with recommendations.
const details = await client.runs.getDetails('my-project', 5);
console.log(details.agents);
console.log(details.recommendations);Agent snapshots returned by runs.save and runs.getDetails preserve both the
normalized model and optional, nullable modelRaw supplied by the API.
Use modelRaw to inspect the original model identity; older responses may omit
it, and records without a raw identity may return null.
client.runs.diff(query)
Compare two runs to see fixed/new issues.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| project | string | Yes | Project name or ID |
| baseRun | number | Yes | Base run number |
| compareRun | number | Yes | Compare run number |
| workflowType | string | No | Filter by workflow type |
const diff = await client.runs.diff({
project: 'my-project',
baseRun: 1,
compareRun: 5,
});
console.log('Fixed issues:', diff.fixed.length);
console.log('New issues:', diff.new.length);
console.log('Unchanged:', diff.unchanged.length);client.runs.archive(input)
Archive old runs.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| project | string | Yes | Project name or ID |
| beforeRunNumber | number | No | Archive runs before this number |
| beforeDate | string | No | Archive runs before this date |
| keepLast | number | No | Keep last N runs |
| reason | string | No | Archive reason |
const result = await client.runs.archive({
project: 'my-project',
keepLast: 10,
reason: 'Quarterly cleanup',
});
console.log(`Archived ${result.archived} runs`);client.runs.update(input, options?)
Update run metadata (tokens, scores). Accepts { _skipClientValidation: true } option.
const run = await client.runs.update({
project: 'my-project',
runNumber: 5,
agents: [
// Token fields are FLAT on update (UpdateAgentInput) — a nested `tokens` object is not a field the update path sends
{ name: 'code-validator', score: 90, inputTokens: 1500 },
],
});Analysis writes are per-agent scoped (API 1a/1b): for each agent named in
analysisRecords / analysisSummary, that agent's existing rows are affected; agents not
named are untouched. Under the default recordWriteMode: 'replace', a named agent's set is
fully replaced — omitting a record it previously had retires it. Under 'merge' (API 1b),
records upsert on (agent_name, record_id): matched rows are superseded, unmatched keys
append, and nothing is retired; summaries have no mode. Preview with previewUpdate first
when unsure. The SDK asserts the server's analysisWrite echo on every analysis-bearing
update — the echoed recordMode must equal the mode this call sent, so a pre-1b server
that strips recordWriteMode (and executes replace on a merge send) throws a named
AnalysisEchoMismatchError instead of silently retiring records (the update has been
applied when this throws — re-read the run rather than retry). To see what a successful
write actually superseded, use updateWithEcho / updateByIdWithEcho, which return
{ run, analysisWrite } — supersededRecords: 0 on an enrichment that expected to
replace means the named agents had no live rows (first enrichment, or attribution drift).
client.runs.updateWithEcho(input, options?) / client.runs.updateByIdWithEcho(runId, input, options?)
Same write as update/updateById, but returns { run, analysisWrite } — the server's
superseded/created counts, the success path's only view of what the write actually did.
analysisWrite is null on non-analysis updates.
const { run, analysisWrite } = await client.runs.updateWithEcho({
project: 'my-project',
runNumber: 5,
recordWriteMode: 'merge',
analysisRecords: [
{ agentName: 'epictetus-analyst', recordType: 'evidence_claim', recordId: 'EC-2',
title: 'Follow-up claim', data: { claim: '...' } },
],
});
// analysisWrite -> { recordMode: 'merge', supersededRecords: 0, supersededSummaries: 0,
// createdRecords: 1, createdSummaries: 0 }
// supersededRecords > createdRecords under merge means prior duplicate rows
// sharing a key collapsed to one (lossy by design — check before merging into
// agents with a duplicate history).client.runs.previewUpdate(input, options?) / client.runs.previewUpdateById(runId, input, options?)
Read-only preview of an analysis-bearing update under the requested recordWriteMode
(default replace): what the write would supersede, create, and — replace only, via
wouldRetireRecordIds — retire by omission (always [] under merge, which cannot
retire). The preview asserts the server echoed the mode you sent — a pre-1b server that
strips the mode throws AnalysisEchoMismatchError (reason: 'preview-mode-mismatch',
nothing written) instead of returning a plan that models the wrong semantics. Accepts
analysis concerns only (analysisRecords, analysisSummary, recordWriteMode); any
other update field throws a named
InputValidationError client-side (the SDK builds the request body from the analysis
fields alone, so the server's own scope-rule 400 is unreachable through it — the
client-side check is what keeps a spread-in update input from being silently narrowed).
const plan = await client.runs.previewUpdate({
project: 'my-project',
runNumber: 5,
analysisRecords: [
{ agentName: 'epictetus-analyst', recordType: 'evidence_claim', recordId: 'EC-1',
title: 'Registry overclaim', data: { claim: '...' } },
],
});
for (const [agent, p] of Object.entries(plan.byAgent)) {
if (p.wouldRetireRecordIds.length > 0) {
console.warn(`${agent}: write would retire ${p.wouldRetireRecordIds.join(', ')}`);
}
}client.runs.updateById(runId, input, options?)
Update run metadata by run UUID (alternative to update which uses project+runNumber). Supports post-hoc enrichment with structured analysis data (v1.7.0) under the same per-agent write semantics (recordWriteMode replace/merge) and echo assertion as update — and, like update, discards the echo on success; use updateByIdWithEcho to see it. Accepts { _skipClientValidation: true } option.
// Basic metadata update
const run = await client.runs.updateById('run-uuid-here', {
agents: [{ name: 'code-validator', score: 92 }],
});
// Enrich with per-agent analysis summaries (v1.7.1)
const run = await client.runs.updateById('run-uuid-here', {
analysisSummary: [
{ agentName: 'epictetus-analyst', decision: 'FACTUAL', score: 82,
categoryScores: [{ name: 'Fact/Judgment Separation', weight: 30, score: 25 }] },
{ agentName: 'epictetus-validator', decision: 'ALIGNED', score: 82 },
],
analysisRecords: [
{ agentName: 'epictetus-analyst', recordType: 'evidence_claim', recordId: 'EC-1',
title: 'Registry overclaim', data: { claim: '...' } },
{ agentName: 'epictetus-forecaster', recordType: 'decay_vector', recordId: 'DV-1',
title: 'Fail-open compounding', data: { timeline: '12-24 months' } },
],
});
// Enrich with explorer structural maps (v1.8.0)
const run = await client.runs.updateById('run-uuid-here', {
analysisSummary: {
agentName: 'bateson-explorer', decision: 'EXPLORED', score: 0,
explorationMaps: [{
metadata: { explorerName: 'bateson-explorer', framework: 'bateson' },
sections: [
{ type: 'topology', label: 'Logical Level Map', entities: [...], relationships: [...] },
{ type: 'agenda', label: 'Inquiry Agenda', questions: [...] },
],
}],
},
});client.runs.delete(runId)
Delete a run.
await client.runs.delete('run-uuid-here');client.runs.getAnalysis(runId)
Get structured analysis records and summaries for a specific run (v0.3.0).
const analysis = await client.runs.getAnalysis('run-uuid-here');
console.log(analysis.records); // Convention inventories, tension maps, decay vectors, etc.
console.log(analysis.summaries); // Per-agent system metrics, epistemic assessmentsclient.runs.getProjectAnalysis(projectId, query)
Get analysis summaries for a project over time (v0.3.0).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| agentName | string | No | Filter by agent (e.g., 'nietzsche-analyst') |
| agentType | string | No | Filter by type ('analyst', 'validator', etc.) |
| decision | string | No | Filter by decision ('VITAL', 'FLOWING', etc.) |
| limit | number | No | Max results |
| offset | number | No | Pagination offset |
const { data, total } = await client.runs.getProjectAnalysis('my-project', {
agentName: 'nietzsche-analyst',
limit: 10,
});
// data[0]: { decision, score, categoryScores, systemMetrics, runNumber, runTimestamp, workflowType }
data.forEach(s => console.log(s.decision, s.systemMetrics));client.runs.queryAnalysisRecords(query)
F02 analysis attribution: set agentType on each record or summary for an unregistered agent (for example, { agentName: 'local-map', agentType: 'explorer', decision: 'TRACED' }). With multiple agents, name the agent explicitly. The API resolves registered agents at the saved execution version and rejects a conflicting declaration. Read agentTypeSource (registry, declared, unresolved, historical inferred or null) alongside agentTypeDefinitionId and agentTypeDefinitionVersion. Legacy inferred attribution reads as unknown; filter with { agentType: 'unknown' } to find it. Later registry changes do not relabel saved analysis. These fields require an API that persists analysis attribution; older responses remain accepted, with missing provenance left unknown.
Cross-project query for analysis records with filters (v0.3.0).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| recordType | string | No | Filter by type ('convention', 'tension', 'decay_vector') |
| classification | string | No | Filter by classification ('CALCIFIED', 'IMMINENT') |
| agentName | string | No | Filter by agent name |
| agentType | string | No | Filter by agent type |
| severity | string | No | Filter by severity |
// Find all calcified conventions across all projects
const { data, total } = await client.runs.queryAnalysisRecords({
recordType: 'convention',
classification: 'CALCIFIED',
});
// data[0]: { recordType, recordId, title, classification, severity, data: { ... } }
console.log(`Found ${total} records`);client.runs.getAgentRunsAnalysis(agentName, query)
Get analysis summaries with run context for a specific agent. Returns analysis decision, score, category scores, system metrics alongside run metadata (number, timestamp, workflow type).
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| agentName | string | Yes | Agent name |
| query.project | string | Yes | Project name or ID |
| query.decision | string | No | Filter by decision |
| query.limit | number | No | Max results (1-100, default 20) |
| query.offset | number | No | Pagination offset |
const { data, total } = await client.runs.getAgentRunsAnalysis('epictetus-validator', {
project: 'my-project',
limit: 10,
});
// data[0]: { decision, score, categoryScores, runNumber, runTimestamp, workflowType, snapshotScore, ... }Issue Operations
Track and manage validation issues.
resolutionRunIdis optional and deprecated on issue responses (since v5.11.0).ops-uluops-apidrops the underlying column in its migration 075: it encoded resolution-by-run, a model the tracker never implemented — runs detect, humans and agents resolve, and no run is in scope at a resolving transition. The column wasNULLon every row, so every response this SDK has parsed carriednullthere. Do not read the field; it is removed in the next major.Upgrade to v5.11.0 before that API deploys. Responses are runtime-parsed, so a required key the API stops sending throws a
ZodErroron every issue read —get,search,listByProject, and every operation embedding an issue — rather than surfacing asnull. v5.11.0 accepts the field present,null, or absent, so it parses both API shapes and can be adopted at any time ahead of the deploy.
mergedIntoIssueIdis available on issue responses (since v5.13.0). When an issue has been merged into another, this carries the surviving issue's UUID;nullmeans it was never merged. Without it there is no way to answer "where did this issue go" from a client —statusreadsmergedand the trail ends, leaving onlystatus_historyprose or direct database access, and production's database is not reachable from a workstation.The key has been on the wire since the API's migration 078. Earlier SDK versions silently discarded it: responses are parsed with
z.object(), which strips unknown keys rather than erroring, so the value was dropped with no error and no warning. If you are on an older SDK you are not seeing anull— you are seeing nothing.The field is an identity relation and read-only: it stays populated when the target is soft-deleted, and the API refuses to set it through any update path. Correlation follows it; the by-fingerprint endpoints deliberately do not.
descriptionis available on issue listings and run recommendations (since v6.1.0). This is the occurrence's own account of a sighting — what the agent actually wrote — as opposed to the issue'stitle. It is what tells you a finding was already resolved: agents routinely end a description with "FIXED IN RUN", and the title alone states the defect in the present tense regardless.It is not a column on
issues. It lives onoccurrences, and only the read paths that derive it carry it:listIssues(the API computes the latest occurrence's description per issue) andruns.getDetails'srecommendations[]. The by-id and by-fingerprint lookups do not supply it and reportundefined.get_issue_detailson the API side has always returned it via the occurrence record.The same silent-strip applies as above, and it has now cost real work: before v6.1.0 the field was undeclared, so
z.object()dropped it and a listing was indistinguishable from findings that genuinely had no description. A remediation pass read the omission as an absence and spent an entire iteration re-investigating seven findings whose descriptions each said they were already fixed. If you are on an older SDK you are not seeingnull— you are seeing nothing.Requires a matching
ops-uluops-api. Against an older API this parses cleanly and yieldsundefined; the field being expressible is the point, sinceundefined("this path did not supply it") andnull("this occurrence recorded none") are now distinguishable where before neither was.
client.issues.create(input)
Create a user-submitted issue.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| project | string | Yes | Project name or ID |
| title | string | Yes | Issue title |
| priority | Priority | Yes | 'critical', 'high', 'suggested', 'backlog' |
| severity | Severity | No | 'critical', 'high', 'medium', 'low', 'info' |
| type | IssueType | No | 'bug', 'feature', 'refactor', etc. |
| filePath | string | No | File path where issue exists |
| lineNumber | number | No | Line number |
| description | string | No | Detailed description |
| failureCode | string | No | Taxonomy code (e.g., 'STR-OMI/H') |
| agent | string | No | Agent name (defaults to 'user-submitted') |
const issue = await client.issues.create({
project: 'my-project',
title: 'Security vulnerability in auth module',
priority: 'critical',
severity: 'critical',
type: 'security',
filePath: 'src/auth/login.ts',
lineNumber: 45,
description: 'SQL injection vulnerability in login query',
failureCode: 'SEM-INC/C',
});client.issues.search(query)
Search issues across projects.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| query | string | No | Search query (omit for filter-only searches) |
