@cavi-ai/api-client
v0.6.0
Published
Provider-agnostic TypeScript client for AI agent runtimes — one RuntimeClient contract spanning Claude (incl. Claude Managed Agents, beta), Codex via OpenAI Responses, Hermes, and OpenClaw, with HTTP + WebSocket RPC, SSE, manifest-driven routing, and grac
Maintainers
Readme
npm install @cavi-ai/api-clientContents
- Why This Package Exists
- Runtime
- Exports
- Quick Start
- 🤖 Claude Managed Agents (Beta)
- Core Concepts
- Common Surfaces
- Secure Credential Handling
- Architecture
- Development
- Contributing
- Code of Conduct
- Security
- License
Why This Package Exists
Building on top of agent runtimes means writing the same plumbing over and over:
authenticated requests, WebSocket RPC, run-event streams, capability snapshots,
route contracts, typed errors, and fallback behavior for when the backend
inevitably hiccups. @cavi-ai/api-client keeps all of it in one reusable,
provider-agnostic package — so your app code focuses on the workflow, not the
transport.
🤔 This package is for you if…
- 🛰️ You run multiple gateways and runtimes across different providers, and you'd rather have one client than a pile of one-off integrations.
- 🎛️ You're building interactive, agentic UI — live runs, streaming events, capability-aware panels — and don't want to rebuild the transport layer every single project.
- ⏰ You don't love the timing of upstream bugs (always mid-demo, never a quiet Tuesday) and want your UI to shrug them off instead of faceplanting.
- 🤷 You've made peace with the one universal constant: humans and agents ship mistakes. So here, degradation is a contract — a backend gap returns typed fallback data with a structured
contractGap, not a white screen of death. - 🔀 You need to switch providers without a rewrite — Hermes today, OpenClaw tomorrow, Claude on Friday — all behind the exact same calls.
- 📐 You want stable, schema-correct endpoints that hand back the same shape no matter which provider answered, so per-provider
if/elsespaghetti never leaks into your components. - 🎉 You want in on a genuinely fun open-source project (MIT, strict-typed to the teeth, conformance-tested — and yes, PRs are actually welcome).
- 🐶🐱 You like puppies and kittens. …alright, we're reaching. But you're still reading, so maybe we're onto something. 😄
🧩 How it holds together
The core is provider-agnostic. Every provider implements one universal
RuntimeClient contract (capabilities · runs · streaming); gateway-style
providers (Hermes, OpenClaw) extend it with GatewayClient (teams, kanban,
workspace, operator), while non-gateway providers (Claude / Anthropic) implement
the runtime tier only. Provider modules customize only what's actually
different — endpoint maps, headers, auth scheme, method transport. The shared
transports, error handling, stream parsing, and trace behavior stay in one
place, and an executable conformance kit keeps every provider honest.
Runtime
- Pure TypeScript ESM with generated
.d.tsfiles. - Node.js
>=20, or any modern runtime withfetchandWebSocket. - Zero runtime dependencies.
- React is an optional peer dependency used only by
@cavi-ai/api-client/frameworks/react. fetchImplcan be supplied anywhere a runtime needs an explicit fetch implementation.
Exports
The root export is a curated stable API: the unified client + provider
registry, the universal RuntimeClient/GatewayClient types, errors and guards,
the graceful-degradation envelope, the auth-seam credential helpers, the team
manifest interface + resolver, and the run-stream contract. Everything else lives
behind a subpath so consumers import only the slice they need:
@cavi-ai/api-client/core/http—BaseHttpApiClient, raw/JSON clients, redaction@cavi-ai/api-client/core/data@cavi-ai/api-client/core/errors@cavi-ai/api-client/core/runtime—RuntimeClient, run-stream contract@cavi-ai/api-client/core/sse@cavi-ai/api-client/core/ws@cavi-ai/api-client/core/gateway— gateway resource clients (media, wiki, agent-config, jobs)@cavi-ai/api-client/core/env@cavi-ai/api-client/contracts@cavi-ai/api-client/extensions/cavi—CaviControlApiClient, registry, portal, library, adapters@cavi-ai/api-client/providers/hermes@cavi-ai/api-client/providers/openclaw@cavi-ai/api-client/providers/claude— Claude (Anthropic): the stateless Messages-API runtime provider and the Managed Agents (beta) surface@cavi-ai/api-client/providers/codex— Codex-flavored OpenAI Responses runtime provider (gpt-5-codex, background runs, polling, cancellation, SSE streaming)@cavi-ai/api-client/frameworks/react
Upgrading from a flat-import version? Provider modules, the CAVI extension, and low-level primitives moved off the root entry to their subpaths. See MIGRATION.md for the import map.
Quick Start
import { createGatewayApiClient } from "@cavi-ai/api-client";
const gateway = createGatewayApiClient({
baseUrl: process.env.GATEWAY_API_BASE_URL!,
auth: {
bearerToken: process.env.GATEWAY_API_AUTH_TOKEN,
clientId: "dashboard",
},
});
const run = await gateway.startRun({
input: "Summarize the current workspace state.",
session_id: "dashboard",
});
// run.run_id / run.status are your handle for polling, streaming, or UI state.
// Failures are typed — see Typed Errors below for how to branch on them.🤖 Claude Managed Agents (Beta)
Anthropic ships a second, far less famous way to run Claude: Managed Agents (beta). Instead of you running the agent loop, Anthropic does — a persisted, versioned agent config spawns stateful sessions, each in its own containerized environment where Claude executes tools (bash, file ops, code), streaming every step over SSE. Skills, MCP servers, file mounts, multiagent threads, and rubric-graded outcomes are all part of it. Most people don't even know it exists.
This package is meant to be the easy on-ramp. It's runtime-only and additive:
the same RuntimeClient you already use, one extra import, no app rewrite, and
nothing about your existing setup has to change. Already have a harness you
like? Keep it — try Managed Agents alongside it and see which fits which job.
import {
ClaudeManagedAgentClient,
driveManagedAgentSession,
} from "@cavi-ai/api-client/providers/claude";
const claude = new ClaudeManagedAgentClient({
apiKey: process.env.ANTHROPIC_API_KEY!, // or `authToken` for an OAuth bearer
});
// Agents + environments are persisted — create them once, reference them by id.
const agent = await claude.createAgent({
name: "researcher",
model: "claude-opus-4-8",
system: "You are a meticulous research assistant.",
});
const env = await claude.createEnvironment({ name: "research-env" });
// A session is one stateful run against that agent in that environment.
const session = await claude.createSession({
agentId: agent.id,
environmentId: env.id,
});
await claude.sendMessage(session.id, { input: "Summarize today's commits." });
// Tail the SSE stream, answer tool confirmations + custom tools, survive drops.
await driveManagedAgentSession(claude, session.id, {
onMessage: (text) => appendAgentText(text),
onToolConfirmation: () => ({ result: "allow" }), // omit → deny
onComplete: () => markRunComplete(),
});Everything is exported from @cavi-ai/api-client/providers/claude and verified
against the live beta API (managed-agents-2026-04-01):
- Sessions & steering —
createSession,sendMessage/sendEvents,interruptSession,confirmTool,respondCustomTool,openEventStream, anddriveManagedAgentSession(a deadlock-safe stream driver with lossless reconnect and dedupe). - Agents & environments — persisted, versioned configs and per-session
containers (
createAgent,updateAgent,createEnvironment). - Typed events —
parseSessionEvent+ a discriminatedManagedAgentSessionEventunion (messages, tool calls, status, errors, outcomes, threads). - Outcomes —
defineOutcomefor rubric-graded session loops. - Multiagent threads —
listThreads,openThreadEventStream, and friends. - Memory stores — full CRUD over stores, memories, and memory versions.
- Vaults & MCP credentials — vault/credential CRUD plus
validateMcpOauthCredentialforstatic_bearerandmcp_oauthauth. - Teams —
buildManagedAgentTeamsPlanmaps aTeamManifestto a coordinator + roster. - Webhook verification —
verifyManagedAgentWebhookimplements the Standard Webhooks signing scheme via Web Crypto (verified against the scheme; live delivery is out of scope for this release). - Self-hosted environments —
getWorkQueueStats/stopWorkobserve and control the work queue (queue monitoring only — the tool-executing worker stays with the host).
The stateless Claude Messages-API client (
ClaudeApiClient, below) is unchanged — Managed Agents is an additive sibling under the same subpath.
Core Concepts
One Client Shape
Every provider implements the universal RuntimeClient contract — capability
profile, runs (startRun/optional getRun/cancelRun), and optional
streamRun. GatewayClient extends RuntimeClient for gateway-style
backends, adding teams, kanban, workspace, and operator surfaces. GatewayApiClient
implements both tiers; a non-gateway provider (e.g. Claude) implements
RuntimeClient only and declares its capability profile so unsupported surfaces
fail with a typed EndpointNotFound rather than a hard crash.
import {
GatewayApiClient,
createGatewayApiClient,
createGatewayProviderRegistry,
} from "@cavi-ai/api-client";
import type { GatewayProviderModule } from "@cavi-ai/api-client/core/gateway";
const provider: GatewayProviderModule = {
kind: "acme",
aliases: ["acme-gateway"],
createApiClient: (options) => new GatewayApiClient(options, "acme-api"),
};
const registry = createGatewayProviderRegistry({ modules: [provider] });
const client = createGatewayApiClient(config.gateway, {
provider: "acme-gateway",
registry,
});Provider modules should reuse core transports. They should not fork JSON request handling, RPC flow, SSE parsing, trace redaction, or error normalization.
Providers
Built-in providers register through the same registry; createRuntimeProviderRegistry
accepts runtime-only modules (no gateway factories required):
import { createRuntimeProviderRegistry } from "@cavi-ai/api-client";
import { HERMES_PROVIDER_MODULE } from "@cavi-ai/api-client/providers/hermes";
import { OPENCLAW_PROVIDER_MODULE } from "@cavi-ai/api-client/providers/openclaw";
import { createClaudeProviderModule } from "@cavi-ai/api-client/providers/claude";
import { createCodexProviderModule } from "@cavi-ai/api-client/providers/codex";
const registry = createRuntimeProviderRegistry({
modules: [
HERMES_PROVIDER_MODULE,
OPENCLAW_PROVIDER_MODULE,
createClaudeProviderModule({ apiKey: process.env.ANTHROPIC_API_KEY! }),
createCodexProviderModule({ apiKey: process.env.OPENAI_API_KEY! }),
],
});
registry.resolveProvider("claude-sdk"); // -> the Claude module
registry.resolveProvider("codex"); // -> the Codex Responses moduleThe Claude (Anthropic) provider is runtime-only — it maps startRun to
POST /v1/messages and streams the Messages SSE into the canonical
RunStreamEvent:
import { ClaudeApiClient } from "@cavi-ai/api-client/providers/claude";
const claude = new ClaudeApiClient({ apiKey: process.env.ANTHROPIC_API_KEY! });
const run = await claude.startRun({
input: "Summarize the workspace state.",
model: "claude-opus-4-8",
instructions: "Be concise.",
});
await claude.streamRun(
{ input: "Stream a haiku.", model: "claude-opus-4-8" },
{
onEvent: (event) => appendRunEvent(event), // canonical RunStreamEvent
onComplete: () => markRunComplete(),
},
);For Claude's stateful, server-run mode — persisted agents, containerized
sessions, and SSE steering — see 🤖 Claude Managed Agents (Beta)
above; it ships from the same @cavi-ai/api-client/providers/claude subpath.
The Codex provider is also runtime-only. It uses the OpenAI Responses API
with gpt-5-codex by default, starts background responses so UIs can poll or
cancel by response id, and streams Responses SSE into canonical
RunStreamEvents:
import { CodexApiClient } from "@cavi-ai/api-client/providers/codex";
const codex = new CodexApiClient({ apiKey: process.env.OPENAI_API_KEY! });
const run = await codex.startRun({
input: "Review this component plan for risks.",
instructions: "Return concise engineering guidance.",
});
await codex.streamRun(
{ input: "Draft the implementation checklist." },
{ onEvent: (event) => appendRunEvent(event) },
);Keep OpenAI API keys backend-owned. Browser and mobile apps should call your
backend, which can instantiate CodexApiClient; they should not embed raw
OpenAI credentials.
Writing your own provider? Point the shared conformance kit
(src/__tests__/support/runtime-conformance.ts) at your client and it must pass
the same contract every built-in provider does.
Credential Schemes
A provider declares its auth scheme through auth.resolveHeaders instead of the
core hardcoding a bearer token. Ready-made resolvers cover the common cases:
import { bearerCredentials, apiKeyCredentials } from "@cavi-ai/api-client";
// Gateway bearer token (the default if you pass auth.bearerToken)
const gatewayAuth = { resolveHeaders: bearerCredentials(process.env.GATEWAY_API_AUTH_TOKEN) };
// Anthropic api-key + version header
const anthropicAuth = {
resolveHeaders: apiKeyCredentials(process.env.ANTHROPIC_API_KEY!, {
header: "x-api-key",
extra: { "anthropic-version": "2023-06-01" },
}),
};A provider can also report a protocol version; assertProtocolVersion(caps, expected)
turns a backend version mismatch into a typed ProtocolMismatch error instead of
a confusing downstream failure.
Typed Errors
Every failure is one of the package's typed classes — HttpApiError (non-2xx,
network failure, abort, invalid JSON), GatewayHttpError (gateway HTTP detail),
GatewayRpcError (WebSocket RPC rejection), or ApiClientError (synthesized
config/validation/transport). Branch on a guard, never on error.message, and
never re-wrap a failure in a generic Error.
import {
getErrorStatus,
isAbortError,
isAuthError,
isHttpApiError,
} from "@cavi-ai/api-client";
try {
await cavi.getOperatorSnapshot();
} catch (error) {
if (isAbortError(error)) return; // request was cancelled
if (isAuthError(error)) return signOut(); // 401/403 across HTTP error classes
if (getErrorStatus(error) === 404) return markUnavailable();
if (isHttpApiError(error)) {
reportError({ status: error.status, path: error.path, body: error.body });
}
throw error; // unknown shape: never swallow it
}isAuthError covers both HttpApiError and GatewayHttpError; getErrorStatus
returns the numeric HTTP status or undefined. Lower-level helpers
(getErrorMessage, serializeError, toError, and the ApiClientErrorType /
ApiClientErrorCode enums) remain available from core/errors.
Antipattern:
// ❌ Message matching breaks on rewording/localization and swallows real failures. catch (e: any) { if (e.message.includes("401")) signOut(); else console.log("request failed"); } // ❌ Re-wrapping erases status, path, body, and the cause chain. throw new Error("snapshot failed");
Graceful Degradation
Gateway loaders can return a typed DataEnvelope<T>:
type DataEnvelope<T> = {
data: T;
source: "gateway" | "mock";
fetchedAt: number;
contractGaps: ContractGap[];
};withFallback and withMutationResult keep expected backend gaps visible
without crashing an entire dashboard. Auth failures and unknown errors still
throw.
Route Mirrors
OpenClaw, Caviclaw plugins, and gateway hosts own their runtime API routes. This package only mirrors those routes so client code has one import path and does not drift into hand-built strings. Mirrored route literals belong in:
- Global contracts:
src/contracts/paths.tsandsrc/contracts/surfaces.ts. - CAVI extension contracts:
src/extensions/cavi/contracts/paths.tsandsrc/extensions/cavi/contracts/surfaces.ts. - OpenClaw provider mirrors:
src/providers/openclaw/manifest.tsandsrc/providers/openclaw/workboard.ts.
Consumers should use exported constants and resolvers such as resolvePath and
appendHttpQuery (root), and resolveCaviPath plus the CAVI contract helpers
(@cavi-ai/api-client/extensions/cavi). Avoid assembling paths by hand in
clients, components, or adapters. New or changed paths must come from the
upstream gateway/plugin contract first.
Team Manifest
The team manifest is an interface, not data the package owns. The package
ships the manifest shape (types + normalization + lookup validation), a
TeamRouteResolver, and a TeamManifestSource "bring-your-own-manifest" seam —
the host supplies the actual team/member/workspace/action data at runtime. CAVI
identity specifics live in identity.metadata, keeping the contract agnostic.
import {
createStaticManifestSource,
createTeamRouteResolver,
normalizeTeamManifest,
resolveTeamWorkspaceApiPath,
type TeamManifest,
} from "@cavi-ai/api-client";
const source = createStaticManifestSource({
version: 1,
teams: [
{
id: "research",
identity: { displayName: "Research", slug: "research", code: "RND" },
workspace: {
rootPath: "/teams/research/workspace",
paths: ["reports", { key: "media.images", path: "media/images" }],
},
members: [{ id: "analyst", capabilities: ["research.read"] }],
},
],
} satisfies TeamManifest);
const manifest = await source.getManifest();
const resolver = createTeamRouteResolver();
const path = resolver.resolveWorkspaceApiPath(manifest, "research", "media.images", {
memberId: "analyst",
});The CAVI team registry (which overlays operator-dispatch data on top of the generic manifest) lives in
@cavi-ai/api-client/extensions/cavi—createTeamRegistry,configureTeamRegistryConfig,TEAM_REGISTRY_CONFIG.
See docs/team-manifest.md and docs/team-manifest.consumer.template.ts for consumer-owned manifest examples.
Common Surfaces
HTTP
import {
CaviControlApiClient,
resolveHttpApiConfigFromEnv,
} from "@cavi-ai/api-client/extensions/cavi";
const config = resolveHttpApiConfigFromEnv(process.env);
const cavi = new CaviControlApiClient({
baseUrl: config.cavi.baseUrl,
auth: {
bearerToken: config.cavi.authToken,
clientId: config.cavi.clientId,
},
});Canonical environment keys:
CAVI_API_BASE_URL,CAVI_API_AUTH_TOKEN,CAVI_API_CLIENT_IDGATEWAY_API_BASE_URL,GATEWAY_API_AUTH_TOKEN,GATEWAY_API_CLIENT_IDLIBRARY_API_BASE_URL,LIBRARY_API_AUTH_TOKEN,LIBRARY_API_CLIENT_ID
Alias keys for common web and mobile app runtimes are supported by default.
Pass { includeAliases: false } to disable aliases.
WebSocket RPC
import { createGatewayWebSocketClient } from "@cavi-ai/api-client";
const ws = createGatewayWebSocketClient(target.wsUrl, authToken, {
clientId: "dashboard",
requestedScopes: ["operator.read"],
});
await ws.connect();
const sessions = await ws.request<{ sessions: unknown[] }>("sessions.list", {
limit: 20,
});Run Event Streams
import { createGatewaySseRunEventProvider } from "@cavi-ai/api-client";
const stream = createGatewaySseRunEventProvider({
httpBase: config.gateway.baseUrl,
authToken: config.gateway.authToken,
clientId: config.gateway.clientId,
sessionKey: "dashboard",
});
// Keep the subscription handle so you can `subscription.dispose()` on unmount.
const subscription = await stream.subscribe(
{ runId: "run-123" },
{
onEvent: (event) => appendRunEvent(event),
onError: (error) => reportError(error),
onComplete: () => markRunComplete(),
},
);Media And Wiki
import {
createGatewayMediaClient,
createGatewayWikiClient,
} from "@cavi-ai/api-client";
const media = createGatewayMediaClient({ baseUrl, auth });
const image = await media.generateImage({
input: "diagram of a workflow",
format: "png",
});
const wiki = createGatewayWikiClient({ baseUrl, auth });
const page = await wiki.readWikiPage("default", "index.qmd");React
import {
GatewayClientProvider,
useGatewayClientContext,
} from "@cavi-ai/api-client/frameworks/react";
function App() {
return (
<GatewayClientProvider
gatewayBaseUrl={gatewayBaseUrl}
authToken={authToken}
clientId="portal"
autoReconnect
>
<Panel />
</GatewayClientProvider>
);
}
function Panel() {
const { client, state, connect } = useGatewayClientContext();
return <button onClick={() => void connect()}>{state}</button>;
}CAVI Extension Adapters
extensions/cavi contains product-shaped composition over the generic core.
The extension mirrors CAVI-specific DTOs, adapters, fallback providers, and
plugin route contracts while still using shared transports and error handling.
import { createCaviControlAdapters } from "@cavi-ai/api-client/extensions/cavi";
const adapters = createCaviControlAdapters({
gatewayBaseUrl,
apiBaseUrl,
authToken,
client: gatewayRpcClient,
fallbackMode: "empty",
});
const overview = await adapters.loadOverview();Secure Credential Handling
The client never persists credentials — applications pass auth.bearerToken and
own storage. On devices that means the OS keychain/keystore or an encrypted
store, never plaintext AsyncStorage or localStorage. Keep the client
storage-agnostic behind one interface:
export interface TokenStore {
get(): Promise<string | null>;
set(token: string): Promise<void>;
clear(): Promise<void>;
}expo-secure-store (Keychain / Keystore) and react-native-mmkv (encrypted
instance) are drop-in backends:
import * as SecureStore from "expo-secure-store";
import { MMKV } from "react-native-mmkv";
const KEY = "cavi.gateway.bearer";
export const secureStoreTokens: TokenStore = {
get: () => SecureStore.getItemAsync(KEY),
set: (token) =>
SecureStore.setItemAsync(KEY, token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}),
clear: () => SecureStore.deleteItemAsync(KEY),
};
const storage = new MMKV({ id: "cavi-auth", encryptionKey: deviceEncryptionKey });
export const mmkvTokens: TokenStore = {
get: async () => storage.getString(KEY) ?? null,
set: async (token) => storage.set(KEY, token),
clear: async () => storage.delete(KEY),
};Build the client from the stored token and refresh only on a typed auth
failure — isAuthError covers HttpApiError and GatewayHttpError, so you
never re-check .status by hand:
import { isAuthError } from "@cavi-ai/api-client";
import { CaviControlApiClient } from "@cavi-ai/api-client/extensions/cavi";
async function withFreshAuth<T>(
tokens: TokenStore,
call: (client: CaviControlApiClient) => Promise<T>,
): Promise<T> {
const build = async () =>
new CaviControlApiClient({
baseUrl: config.cavi.baseUrl,
auth: { bearerToken: (await tokens.get()) ?? undefined, clientId: "cavi-mobile" },
});
try {
return await call(await build());
} catch (error) {
if (!isAuthError(error)) throw error; // only refresh on 401/403
await tokens.set(await refreshAccessToken());
return call(await build());
}
}Avoid logging raw request headers, auth tokens, or full error bodies; trace helpers redact sensitive values before emitting previews.
Antipattern:
// ❌ Plaintext token on disk — readable by backups, other apps, rooted devices. await AsyncStorage.setItem("token", token); localStorage.setItem("token", token);
Architecture
See ARCHITECTURE.md for the layer map, provider boundary, route mirror rules, and extension/plugin split.
Development
pnpm install
pnpm test
pnpm run build
pnpm run lint:md
pnpm run verifyStrict TypeScript is the lint gate. Relative source imports use .js
extensions. Tests live under src/__tests__/**.
The hardening tests in
src/__tests__/package-hardening.test.ts
enforce package boundaries, path ownership, forbidden imports, and output shape.
Update them only when the package boundary intentionally changes.
Contributing
See CONTRIBUTING.md for workflow, provider-author guidance, and boundary rules.
Code of Conduct
Participation in this project is covered by CODE_OF_CONDUCT.md.
Security
See SECURITY.md for vulnerability reporting.
